diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
index 5c1446662ef..ef0747707ff 100644
--- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
+++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
@@ -1,6 +1,7 @@
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
+import { CanvasWorkflowIntegrationModal } from 'features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { CropImageModal } from 'features/cropper/components/CropImageModal';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
@@ -51,6 +52,7 @@ export const GlobalModalIsolator = memo(() => {
+
diff --git a/invokeai/frontend/web/src/app/logging/logger.ts b/invokeai/frontend/web/src/app/logging/logger.ts
index 6c843068df3..d20ef77090f 100644
--- a/invokeai/frontend/web/src/app/logging/logger.ts
+++ b/invokeai/frontend/web/src/app/logging/logger.ts
@@ -16,6 +16,7 @@ const $logger = atom(Roarr.child(BASE_CONTEXT));
export const zLogNamespace = z.enum([
'canvas',
+ 'canvas-workflow-integration',
'config',
'dnd',
'events',
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index 3fc6b893a29..9152fd131d6 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -23,6 +23,7 @@ import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/sli
import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice';
import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice';
import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import { canvasWorkflowIntegrationSliceConfig } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice';
import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice';
import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice';
@@ -62,6 +63,7 @@ const SLICE_CONFIGS = {
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
+ [canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig,
@@ -91,6 +93,7 @@ const ALL_REDUCERS = {
canvasSliceConfig.slice.reducer,
canvasSliceConfig.undoableConfig?.reduxUndoOptions
),
+ [canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig.slice.reducer,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig.slice.reducer,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig.slice.reducer,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig.slice.reducer,
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal.tsx
new file mode 100644
index 00000000000..facbe1fbd88
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal.tsx
@@ -0,0 +1,96 @@
+import {
+ Button,
+ ButtonGroup,
+ Flex,
+ Heading,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Spacer,
+ Spinner,
+ Text,
+} from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import {
+ canvasWorkflowIntegrationClosed,
+ selectCanvasWorkflowIntegrationIsOpen,
+ selectCanvasWorkflowIntegrationIsProcessing,
+ selectCanvasWorkflowIntegrationSelectedWorkflowId,
+} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { CanvasWorkflowIntegrationParameterPanel } from './CanvasWorkflowIntegrationParameterPanel';
+import { CanvasWorkflowIntegrationWorkflowSelector } from './CanvasWorkflowIntegrationWorkflowSelector';
+import { useCanvasWorkflowIntegrationExecute } from './useCanvasWorkflowIntegrationExecute';
+
+export const CanvasWorkflowIntegrationModal = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+
+ const isOpen = useAppSelector(selectCanvasWorkflowIntegrationIsOpen);
+ const isProcessing = useAppSelector(selectCanvasWorkflowIntegrationIsProcessing);
+ const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId);
+
+ const { execute, canExecute } = useCanvasWorkflowIntegrationExecute();
+
+ const onClose = useCallback(() => {
+ if (!isProcessing) {
+ dispatch(canvasWorkflowIntegrationClosed());
+ }
+ }, [dispatch, isProcessing]);
+
+ const onExecute = useCallback(() => {
+ execute();
+ }, [execute]);
+
+ return (
+
+
+
+
+ {t('controlLayers.workflowIntegration.title', 'Run Workflow on Canvas')}
+
+
+
+
+
+
+ {t(
+ 'controlLayers.workflowIntegration.description',
+ 'Select a workflow with Form Builder and an image parameter to run on the current canvas layer. You can adjust parameters before executing. The result will be added back to the canvas.'
+ )}
+
+
+
+
+ {selectedWorkflowId && }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+CanvasWorkflowIntegrationModal.displayName = 'CanvasWorkflowIntegrationModal';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx
new file mode 100644
index 00000000000..f59a6c45edb
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx
@@ -0,0 +1,13 @@
+import { Box } from '@invoke-ai/ui-library';
+import { WorkflowFormPreview } from 'features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview';
+import { memo } from 'react';
+
+export const CanvasWorkflowIntegrationParameterPanel = memo(() => {
+ return (
+
+
+
+ );
+});
+
+CanvasWorkflowIntegrationParameterPanel.displayName = 'CanvasWorkflowIntegrationParameterPanel';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx
new file mode 100644
index 00000000000..c8de6e2a96a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx
@@ -0,0 +1,95 @@
+import { Flex, FormControl, FormLabel, Select, Spinner, Text } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import {
+ canvasWorkflowIntegrationWorkflowSelected,
+ selectCanvasWorkflowIntegrationSelectedWorkflowId,
+} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
+import type { ChangeEvent } from 'react';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
+
+import { useFilteredWorkflows } from './useFilteredWorkflows';
+
+export const CanvasWorkflowIntegrationWorkflowSelector = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+
+ const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId);
+ const { data: workflowsData, isLoading } = useListWorkflowsInfiniteInfiniteQuery(
+ {
+ per_page: 100, // Get a reasonable number of workflows
+ page: 0,
+ },
+ {
+ selectFromResult: ({ data, isLoading }) => ({
+ data,
+ isLoading,
+ }),
+ }
+ );
+
+ const workflows = useMemo(() => {
+ if (!workflowsData) {
+ return [];
+ }
+ // Flatten all pages into a single list
+ return workflowsData.pages.flatMap((page) => page.items);
+ }, [workflowsData]);
+
+ // Filter workflows to only show those with ImageFields
+ const { filteredWorkflows, isFiltering } = useFilteredWorkflows(workflows);
+
+ const onChange = useCallback(
+ (e: ChangeEvent) => {
+ const workflowId = e.target.value || null;
+ dispatch(canvasWorkflowIntegrationWorkflowSelected({ workflowId }));
+ },
+ [dispatch]
+ );
+
+ if (isLoading || isFiltering) {
+ return (
+
+
+
+ {isFiltering
+ ? t('controlLayers.workflowIntegration.filteringWorkflows', 'Filtering workflows...')
+ : t('controlLayers.workflowIntegration.loadingWorkflows', 'Loading workflows...')}
+
+
+ );
+ }
+
+ if (filteredWorkflows.length === 0) {
+ return (
+
+ {workflows.length === 0
+ ? t('controlLayers.workflowIntegration.noWorkflowsFound', 'No workflows found.')
+ : t(
+ 'controlLayers.workflowIntegration.noWorkflowsWithImageField',
+ 'No workflows with Form Builder and image input fields found. Create a workflow with the Form Builder and add an image field.'
+ )}
+
+ );
+ }
+
+ return (
+
+ {t('controlLayers.workflowIntegration.selectWorkflow', 'Select Workflow')}
+
+
+ );
+});
+
+CanvasWorkflowIntegrationWorkflowSelector.displayName = 'CanvasWorkflowIntegrationWorkflowSelector';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer.tsx
new file mode 100644
index 00000000000..5ff01e86e7b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer.tsx
@@ -0,0 +1,540 @@
+import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
+import {
+ Combobox,
+ Flex,
+ FormControl,
+ FormLabel,
+ IconButton,
+ Input,
+ Radio,
+ Select,
+ Switch,
+ Text,
+ Textarea,
+} from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { skipToken } from '@reduxjs/toolkit/query';
+import { logger } from 'app/logging/logger';
+import { EMPTY_ARRAY } from 'app/store/constants';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
+import {
+ canvasWorkflowIntegrationFieldValueChanged,
+ canvasWorkflowIntegrationImageFieldSelected,
+ selectCanvasWorkflowIntegrationFieldValues,
+ selectCanvasWorkflowIntegrationSelectedImageFieldKey,
+ selectCanvasWorkflowIntegrationSelectedWorkflowId,
+} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
+import { DndImage } from 'features/dnd/DndImage';
+import { ModelFieldCombobox } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelFieldCombobox';
+import { $templates } from 'features/nodes/store/nodesSlice';
+import type { NodeFieldElement } from 'features/nodes/types/workflow';
+import { SCHEDULER_OPTIONS } from 'features/parameters/types/constants';
+import { isParameterScheduler } from 'features/parameters/types/parameterSchemas';
+import type { ChangeEvent } from 'react';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiTrashSimpleBold } from 'react-icons/pi';
+import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
+import { useGetImageDTOQuery } from 'services/api/endpoints/images';
+import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
+import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
+import type { AnyModelConfig, ImageDTO } from 'services/api/types';
+
+const log = logger('canvas-workflow-integration');
+
+interface WorkflowFieldRendererProps {
+ el: NodeFieldElement;
+}
+
+export const WorkflowFieldRenderer = memo(({ el }: WorkflowFieldRendererProps) => {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId);
+ const fieldValues = useAppSelector(selectCanvasWorkflowIntegrationFieldValues);
+ const selectedImageFieldKey = useAppSelector(selectCanvasWorkflowIntegrationSelectedImageFieldKey);
+ const templates = useStore($templates);
+
+ const { data: workflow } = useGetWorkflowQuery(selectedWorkflowId!, {
+ skip: !selectedWorkflowId,
+ });
+
+ // Load boards and models for BoardField and ModelIdentifierField
+ const { data: boardsData } = useListAllBoardsQuery({ include_archived: true });
+ const { data: modelsData, isLoading: isLoadingModels } = useGetModelConfigsQuery();
+
+ const { fieldIdentifier } = el.data;
+ const fieldKey = `${fieldIdentifier.nodeId}.${fieldIdentifier.fieldName}`;
+
+ log.debug({ fieldIdentifier, fieldKey }, 'Rendering workflow field');
+
+ // Get the node, field instance, and field template
+ const { field, fieldTemplate } = useMemo(() => {
+ if (!workflow?.workflow.nodes) {
+ log.warn('No workflow nodes found');
+ return { field: null, fieldTemplate: null };
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const foundNode = workflow.workflow.nodes.find((n: any) => n.data.id === fieldIdentifier.nodeId);
+ if (!foundNode) {
+ log.warn({ nodeId: fieldIdentifier.nodeId }, 'Node not found');
+ return { field: null, fieldTemplate: null };
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const foundField = (foundNode.data as any).inputs[fieldIdentifier.fieldName];
+ if (!foundField) {
+ log.warn({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName }, 'Field not found in node');
+ return { field: null, fieldTemplate: null };
+ }
+
+ // Get the field template from the invocation templates
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const nodeType = (foundNode.data as any).type;
+ const template = templates[nodeType];
+ if (!template) {
+ log.warn({ nodeType }, 'No template found for node type');
+ return { field: foundField, fieldTemplate: null };
+ }
+
+ const foundFieldTemplate = template.inputs[fieldIdentifier.fieldName];
+ if (!foundFieldTemplate) {
+ log.warn({ nodeType, fieldName: fieldIdentifier.fieldName }, 'Field template not found');
+ return { field: foundField, fieldTemplate: null };
+ }
+
+ return { field: foundField, fieldTemplate: foundFieldTemplate };
+ }, [workflow, fieldIdentifier, templates]);
+
+ // Get the current value from Redux or fallback to field default
+ const currentValue = useMemo(() => {
+ if (fieldValues && fieldKey in fieldValues) {
+ return fieldValues[fieldKey];
+ }
+
+ return field?.value ?? fieldTemplate?.default ?? '';
+ }, [fieldValues, fieldKey, field, fieldTemplate]);
+
+ // Get field type from the template
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fieldType = fieldTemplate ? (fieldTemplate as any).type?.name : null;
+
+ const handleChange = useCallback(
+ (value: unknown) => {
+ dispatch(canvasWorkflowIntegrationFieldValueChanged({ fieldName: fieldKey, value }));
+ },
+ [dispatch, fieldKey]
+ );
+
+ const handleStringChange = useCallback(
+ (e: ChangeEvent) => {
+ handleChange(e.target.value);
+ },
+ [handleChange]
+ );
+
+ const handleNumberChange = useCallback(
+ (e: ChangeEvent) => {
+ const val = fieldType === 'IntegerField' ? parseInt(e.target.value, 10) : parseFloat(e.target.value);
+ handleChange(isNaN(val) ? 0 : val);
+ },
+ [handleChange, fieldType]
+ );
+
+ const handleBooleanChange = useCallback(
+ (e: ChangeEvent) => {
+ handleChange(e.target.checked);
+ },
+ [handleChange]
+ );
+
+ const handleSelectChange = useCallback(
+ (e: ChangeEvent) => {
+ handleChange(e.target.value);
+ },
+ [handleChange]
+ );
+
+ // SchedulerField handlers
+ const handleSchedulerChange = useCallback(
+ (v) => {
+ if (!isParameterScheduler(v?.value)) {
+ return;
+ }
+ handleChange(v.value);
+ },
+ [handleChange]
+ );
+
+ const schedulerValue = useMemo(() => SCHEDULER_OPTIONS.find((o) => o.value === currentValue), [currentValue]);
+
+ // BoardField handlers
+ const handleBoardChange = useCallback(
+ (v) => {
+ if (!v) {
+ return;
+ }
+ const value = v.value === 'auto' || v.value === 'none' ? v.value : { board_id: v.value };
+ handleChange(value);
+ },
+ [handleChange]
+ );
+
+ const boardOptions = useMemo(() => {
+ const _options: ComboboxOption[] = [
+ { label: t('common.auto'), value: 'auto' },
+ { label: `${t('common.none')} (${t('boards.uncategorized')})`, value: 'none' },
+ ];
+ if (boardsData) {
+ for (const board of boardsData) {
+ _options.push({
+ label: board.board_name,
+ value: board.board_id,
+ });
+ }
+ }
+ return _options;
+ }, [boardsData, t]);
+
+ const boardValue = useMemo(() => {
+ const _value = currentValue;
+ const autoOption = boardOptions[0];
+ const noneOption = boardOptions[1];
+ if (!_value || _value === 'auto') {
+ return autoOption;
+ }
+ if (_value === 'none') {
+ return noneOption;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const boardId = typeof _value === 'object' ? (_value as any).board_id : _value;
+ const boardOption = boardOptions.find((o) => o.value === boardId);
+ return boardOption ?? autoOption;
+ }, [currentValue, boardOptions]);
+
+ const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
+
+ // ModelIdentifierField handlers
+ const handleModelChange = useCallback(
+ (value: AnyModelConfig | null) => {
+ if (!value) {
+ return;
+ }
+ handleChange(value);
+ },
+ [handleChange]
+ );
+
+ const modelConfigs = useMemo(() => {
+ if (!modelsData) {
+ return EMPTY_ARRAY;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const ui_model_base = fieldTemplate ? (fieldTemplate as any)?.ui_model_base : null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const ui_model_type = fieldTemplate ? (fieldTemplate as any)?.ui_model_type : null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const ui_model_variant = fieldTemplate ? (fieldTemplate as any)?.ui_model_variant : null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const ui_model_format = fieldTemplate ? (fieldTemplate as any)?.ui_model_format : null;
+
+ if (!ui_model_base && !ui_model_type) {
+ return modelConfigsAdapterSelectors.selectAll(modelsData);
+ }
+
+ return modelConfigsAdapterSelectors.selectAll(modelsData).filter((config) => {
+ if (ui_model_base && !ui_model_base.includes(config.base)) {
+ return false;
+ }
+ if (ui_model_type && !ui_model_type.includes(config.type)) {
+ return false;
+ }
+ if (ui_model_variant && 'variant' in config && config.variant && !ui_model_variant.includes(config.variant)) {
+ return false;
+ }
+ if (ui_model_format && !ui_model_format.includes(config.format)) {
+ return false;
+ }
+ return true;
+ });
+ }, [modelsData, fieldTemplate]);
+
+ // ImageField handler
+ const handleImageFieldSelect = useCallback(() => {
+ dispatch(canvasWorkflowIntegrationImageFieldSelected({ fieldKey }));
+ }, [dispatch, fieldKey]);
+
+ if (!field || !fieldTemplate) {
+ log.warn({ fieldIdentifier }, 'Field or template is null - not rendering');
+ return null;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const label = (field as any)?.label || (fieldTemplate as any)?.title || fieldIdentifier.fieldName;
+
+ // Log the entire field structure to understand its shape
+ log.debug(
+ { fieldType, label, currentValue, fieldStructure: field, fieldTemplateStructure: fieldTemplate },
+ 'Field info'
+ );
+
+ // ImageField - allow user to select which one receives the canvas image
+ if (fieldType === 'ImageField') {
+ return (
+
+ );
+ }
+
+ // Render different input types based on field type
+ if (fieldType === 'StringField') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const isTextarea = (fieldTemplate as any)?.ui_component === 'textarea';
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const isRequired = (fieldTemplate as any)?.required ?? false;
+
+ if (isTextarea) {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ if (fieldType === 'IntegerField' || fieldType === 'FloatField') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const min = (fieldTemplate as any)?.minimum;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const max = (fieldTemplate as any)?.maximum;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const step = fieldType === 'IntegerField' ? 1 : ((fieldTemplate as any)?.multipleOf ?? 0.01);
+
+ return (
+
+ {label}
+
+
+
+
+ );
+ }
+
+ if (fieldType === 'BooleanField') {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ if (fieldType === 'EnumField') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const options = (fieldTemplate as any)?.options ?? (fieldTemplate as any)?.ui_choice_labels ?? [];
+ const optionsList = Array.isArray(options) ? options : Object.keys(options);
+
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ if (fieldType === 'SchedulerField') {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ if (fieldType === 'BoardField') {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ if (fieldType === 'ModelIdentifierField') {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ // For other field types, show a read-only message
+ log.warn(`Unsupported field type "${fieldType}" for field "${label}" - showing as read-only`);
+ return (
+
+ {label}
+
+
+ );
+});
+
+WorkflowFieldRenderer.displayName = 'WorkflowFieldRenderer';
+
+// Separate component for ImageField to avoid conditional hooks
+interface ImageFieldComponentProps {
+ label: string;
+ fieldKey: string;
+ currentValue: unknown;
+ selectedImageFieldKey: string | null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fieldTemplate: any;
+ handleImageFieldSelect: () => void;
+ handleChange: (value: unknown) => void;
+}
+
+const ImageFieldComponent = memo(
+ ({
+ label,
+ fieldKey,
+ currentValue,
+ selectedImageFieldKey,
+ fieldTemplate,
+ handleImageFieldSelect,
+ handleChange,
+ }: ImageFieldComponentProps) => {
+ const { t } = useTranslation();
+
+ const isSelected = selectedImageFieldKey === fieldKey;
+
+ // Get image from field values (uploaded image) or from workflow field (default/saved image)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const imageValue = currentValue as any;
+ const imageName = imageValue?.image_name;
+
+ const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
+
+ const handleImageUpload = useCallback(
+ (uploadedImage: ImageDTO) => {
+ handleChange(uploadedImage);
+ },
+ [handleChange]
+ );
+
+ const handleImageClear = useCallback(() => {
+ handleChange(undefined);
+ }, [handleChange]);
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ {isSelected
+ ? t('controlLayers.workflowIntegration.imageFieldSelected', 'This field will receive the canvas image')
+ : t('controlLayers.workflowIntegration.imageFieldNotSelected', 'Click to use this field for canvas image')}
+
+
+ {/* Show image upload/preview for non-selected fields */}
+ {!isSelected && (
+
+ {!imageDTO && (
+
+ )}
+ {imageDTO && (
+
+
+
+ {`${imageDTO.width}x${imageDTO.height}`}
+
+ }
+ onClick={handleImageClear}
+ size="sm"
+ variant="ghost"
+ colorScheme="error"
+ />
+
+ )}
+
+ )}
+
+ );
+ }
+);
+
+ImageFieldComponent.displayName = 'ImageFieldComponent';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx
new file mode 100644
index 00000000000..942582bd88d
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx
@@ -0,0 +1,224 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Box, Flex, Spinner, Text } from '@invoke-ai/ui-library';
+import { logger } from 'app/logging/logger';
+import { useAppSelector } from 'app/store/storeHooks';
+import { WorkflowFieldRenderer } from 'features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer';
+import { selectCanvasWorkflowIntegrationSelectedWorkflowId } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
+import {
+ ContainerContextProvider,
+ DepthContextProvider,
+ useContainerContext,
+ useDepthContext,
+} from 'features/nodes/components/sidePanel/builder/contexts';
+import { DividerElement } from 'features/nodes/components/sidePanel/builder/DividerElement';
+import { HeadingElement } from 'features/nodes/components/sidePanel/builder/HeadingElement';
+import { TextElement } from 'features/nodes/components/sidePanel/builder/TextElement';
+import type { FormElement } from 'features/nodes/types/workflow';
+import {
+ CONTAINER_CLASS_NAME,
+ isContainerElement,
+ isDividerElement,
+ isHeadingElement,
+ isNodeFieldElement,
+ isTextElement,
+ ROOT_CONTAINER_CLASS_NAME,
+} from 'features/nodes/types/workflow';
+import { memo, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
+
+const log = logger('canvas-workflow-integration');
+
+const rootViewModeSx: SystemStyleObject = {
+ borderRadius: 'base',
+ w: 'full',
+ h: 'full',
+ gap: 2,
+ display: 'flex',
+ flex: 1,
+ maxW: '768px',
+ '&[data-self-layout="column"]': {
+ flexDir: 'column',
+ alignItems: 'stretch',
+ },
+ '&[data-self-layout="row"]': {
+ flexDir: 'row',
+ alignItems: 'flex-start',
+ },
+};
+
+const containerViewModeSx: SystemStyleObject = {
+ gap: 2,
+ '&[data-self-layout="column"]': {
+ flexDir: 'column',
+ alignItems: 'stretch',
+ },
+ '&[data-self-layout="row"]': {
+ flexDir: 'row',
+ alignItems: 'flex-start',
+ overflowX: 'auto',
+ overflowY: 'visible',
+ h: 'min-content',
+ flexShrink: 0,
+ },
+ '&[data-parent-layout="column"]': {
+ w: 'full',
+ h: 'min-content',
+ },
+ '&[data-parent-layout="row"]': {
+ flex: '1 1 0',
+ minW: 32,
+ },
+};
+
+export const WorkflowFormPreview = memo(() => {
+ const { t } = useTranslation();
+ const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId);
+
+ const { data: workflow, isLoading } = useGetWorkflowQuery(selectedWorkflowId!, {
+ skip: !selectedWorkflowId,
+ });
+
+ const elements = useMemo((): Record => {
+ if (!workflow?.workflow.form) {
+ return {};
+ }
+ const els = workflow.workflow.form.elements as Record;
+ log.debug({ elementCount: Object.keys(els).length, elementIds: Object.keys(els) }, 'Form elements loaded');
+ return els;
+ }, [workflow]);
+
+ const rootElementId = useMemo((): string => {
+ if (!workflow?.workflow.form) {
+ return '';
+ }
+ const rootId = workflow.workflow.form.rootElementId as string;
+ log.debug({ rootElementId: rootId }, 'Root element ID');
+ return rootId;
+ }, [workflow]);
+
+ if (isLoading) {
+ return (
+
+
+ {t('controlLayers.workflowIntegration.loadingParameters', 'Loading workflow parameters...')}
+
+ );
+ }
+
+ if (!workflow) {
+ return null;
+ }
+
+ // If workflow has no form builder, it should have been filtered out
+ // This is a fallback in case something went wrong
+ if (Object.keys(elements).length === 0 || !rootElementId) {
+ return (
+
+ {t(
+ 'controlLayers.workflowIntegration.noFormBuilderError',
+ 'This workflow has no form builder and cannot be used. Please select a different workflow.'
+ )}
+
+ );
+ }
+
+ const rootElement = elements[rootElementId];
+
+ if (!rootElement || !isContainerElement(rootElement)) {
+ return null;
+ }
+
+ const { id, data } = rootElement;
+ const { children, layout } = data;
+
+ return (
+
+
+
+ {children.map((childId) => (
+
+ ))}
+
+
+
+ );
+});
+WorkflowFormPreview.displayName = 'WorkflowFormPreview';
+
+const FormElementComponentPreview = memo(({ id, elements }: { id: string; elements: Record }) => {
+ const el = elements[id];
+
+ if (!el) {
+ log.warn({ id }, 'Element not found in elements map');
+ return null;
+ }
+
+ log.debug({ id, type: el.type }, 'Rendering form element');
+
+ if (isContainerElement(el)) {
+ return ;
+ }
+
+ if (isDividerElement(el)) {
+ return ;
+ }
+
+ if (isHeadingElement(el)) {
+ return ;
+ }
+
+ if (isTextElement(el)) {
+ return ;
+ }
+
+ if (isNodeFieldElement(el)) {
+ return ;
+ }
+
+ // If we get here, it's an unknown element type
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ log.warn({ id, type: (el as any).type }, 'Unknown element type - not rendering');
+ return null;
+});
+FormElementComponentPreview.displayName = 'FormElementComponentPreview';
+
+const ContainerElementPreview = memo(({ el, elements }: { el: FormElement; elements: Record }) => {
+ const { t } = useTranslation();
+ const depth = useDepthContext();
+ const containerCtx = useContainerContext();
+
+ if (!isContainerElement(el)) {
+ return null;
+ }
+
+ const { id, data } = el;
+ const { children, layout } = data;
+
+ return (
+
+
+
+ {children.map((childId) => (
+
+ ))}
+ {children.length === 0 && (
+
+
+ {t('workflows.builder.emptyContainer')}
+
+
+ )}
+
+
+
+ );
+});
+ContainerElementPreview.displayName = 'ContainerElementPreview';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useCanvasWorkflowIntegrationExecute.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useCanvasWorkflowIntegrationExecute.tsx
new file mode 100644
index 00000000000..3ccfa00749e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useCanvasWorkflowIntegrationExecute.tsx
@@ -0,0 +1,305 @@
+import { logger } from 'app/logging/logger';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import {
+ canvasWorkflowIntegrationClosed,
+ canvasWorkflowIntegrationProcessingStarted,
+ selectCanvasWorkflowIntegrationFieldValues,
+ selectCanvasWorkflowIntegrationSelectedImageFieldKey,
+ selectCanvasWorkflowIntegrationSelectedWorkflowId,
+ selectCanvasWorkflowIntegrationSourceEntityIdentifier,
+} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
+import { CANVAS_OUTPUT_PREFIX, getPrefixedId } from 'features/nodes/util/graph/graphBuilderUtils';
+import { toast } from 'features/toast/toast';
+import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { queueApi } from 'services/api/endpoints/queue';
+import { useLazyGetWorkflowQuery } from 'services/api/endpoints/workflows';
+
+const log = logger('canvas-workflow-integration');
+
+export const useCanvasWorkflowIntegrationExecute = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const canvasManager = useCanvasManager();
+
+ const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId);
+ const sourceEntityIdentifier = useAppSelector(selectCanvasWorkflowIntegrationSourceEntityIdentifier);
+ const fieldValues = useAppSelector(selectCanvasWorkflowIntegrationFieldValues);
+ const selectedImageFieldKey = useAppSelector(selectCanvasWorkflowIntegrationSelectedImageFieldKey);
+ const canvasSessionId = useAppSelector(selectCanvasSessionId);
+
+ const [getWorkflow] = useLazyGetWorkflowQuery();
+
+ const canExecute = useMemo(() => {
+ return Boolean(selectedWorkflowId && sourceEntityIdentifier);
+ }, [selectedWorkflowId, sourceEntityIdentifier]);
+
+ const execute = useCallback(async () => {
+ if (!selectedWorkflowId || !sourceEntityIdentifier || !canvasManager) {
+ return;
+ }
+
+ try {
+ dispatch(canvasWorkflowIntegrationProcessingStarted());
+
+ // 1. Extract the canvas layer as an image
+ const adapter = canvasManager.getAdapter(sourceEntityIdentifier);
+ if (!adapter) {
+ throw new Error('Could not find canvas entity adapter');
+ }
+
+ const rect = adapter.transformer.getRelativeRect();
+ const imageDTO = await adapter.renderer.rasterize({ rect, attrs: { filters: [], opacity: 1 } });
+
+ // 2. Fetch the workflow
+ const { data: workflow } = await getWorkflow(selectedWorkflowId);
+ if (!workflow) {
+ throw new Error('Failed to load workflow');
+ }
+
+ // 3. Build the workflow graph with the canvas image
+ // Use the user-selected ImageField, or find one automatically
+ let imageFieldIdentifier: { nodeId: string; fieldName: string } | undefined;
+
+ // Method 1: Use user-selected ImageField (preferred)
+ if (selectedImageFieldKey) {
+ const [nodeId, fieldName] = selectedImageFieldKey.split('.');
+ if (nodeId && fieldName) {
+ imageFieldIdentifier = { nodeId, fieldName };
+ }
+ }
+
+ // Method 2: Search through form elements for an ImageField (fallback)
+ if (!imageFieldIdentifier && workflow.workflow.form && workflow.workflow.form.elements) {
+ for (const element of Object.values(workflow.workflow.form.elements)) {
+ if (element.type !== 'node-field') {
+ continue;
+ }
+
+ const fieldIdentifier = element.data?.fieldIdentifier;
+ if (!fieldIdentifier) {
+ continue;
+ }
+
+ // @ts-expect-error - node data type is complex
+ const node = workflow.workflow.nodes.find((n) => n.data.id === fieldIdentifier.nodeId);
+ if (!node) {
+ continue;
+ }
+
+ // @ts-expect-error - node.data type is complex
+ if (node.data.type === 'image') {
+ imageFieldIdentifier = fieldIdentifier;
+ break;
+ }
+
+ // Check if field type is ImageField
+ // @ts-expect-error - field type is complex
+ const field = node.data.inputs[fieldIdentifier.fieldName];
+ if (field?.type?.name === 'ImageField') {
+ imageFieldIdentifier = fieldIdentifier;
+ break;
+ }
+ }
+ }
+
+ // Method 3: Fallback to exposedFields
+ if (!imageFieldIdentifier && workflow.workflow.exposedFields) {
+ imageFieldIdentifier = workflow.workflow.exposedFields.find((fieldIdentifier) => {
+ // @ts-expect-error - node data type is complex
+ const node = workflow.workflow.nodes.find((n) => n.data.id === fieldIdentifier.nodeId);
+ if (!node) {
+ return false;
+ }
+
+ // @ts-expect-error - node.data type is complex
+ if (node.data.type === 'image') {
+ return true;
+ }
+
+ // Check if field type is ImageField
+ // @ts-expect-error - field type is complex
+ const field = node.data.inputs[fieldIdentifier.fieldName];
+ return field?.type?.name === 'ImageField';
+ });
+ }
+
+ if (!imageFieldIdentifier) {
+ throw new Error('Workflow does not have an image input field in the Form Builder');
+ }
+
+ // Update the workflow nodes with our values
+ const updatedWorkflow = {
+ ...workflow.workflow,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ nodes: workflow.workflow.nodes.map((node: any) => {
+ const nodeId = node.data.id;
+ let updatedInputs = { ...node.data.inputs };
+ let updatedData = { ...node.data };
+ let hasChanges = false;
+
+ // Apply image field if this is the image node
+ if (nodeId === imageFieldIdentifier.nodeId) {
+ updatedInputs[imageFieldIdentifier.fieldName] = {
+ ...updatedInputs[imageFieldIdentifier.fieldName],
+ value: imageDTO,
+ };
+ hasChanges = true;
+ }
+
+ // Apply any field values from Redux state
+ if (fieldValues) {
+ Object.entries(fieldValues).forEach(([fieldKey, value]) => {
+ const [fieldNodeId, fieldName] = fieldKey.split('.');
+ if (fieldNodeId && fieldName && fieldNodeId === nodeId && updatedInputs[fieldName]) {
+ updatedInputs[fieldName] = {
+ ...updatedInputs[fieldName],
+ value: value,
+ };
+ hasChanges = true;
+ }
+ });
+ }
+
+ // Configure output nodes to go to staging area
+ // board_id field determines where images are saved
+ if (updatedInputs.board !== undefined) {
+ updatedInputs.board = {
+ ...updatedInputs.board,
+ value: undefined,
+ };
+ hasChanges = true;
+ }
+
+ // Set isIntermediate to true (like normal canvas operations)
+ // This is a node-level property, not an input field
+ if (updatedData.isIntermediate !== undefined) {
+ updatedData.isIntermediate = true;
+ hasChanges = true;
+ }
+
+ // If anything was modified, return updated node
+ if (hasChanges) {
+ updatedData.inputs = updatedInputs;
+ return {
+ ...node,
+ data: updatedData,
+ };
+ }
+
+ return node;
+ }),
+ };
+
+ // 4. Convert workflow to graph format
+ // We need to manually convert the workflow nodes to invocation graph nodes
+ const graphNodes: Record = {};
+ const nodeIdMapping: Record = {}; // Map original IDs to new IDs
+
+ for (const node of updatedWorkflow.nodes) {
+ const nodeData = node.data;
+
+ // Check if this is an output node (has a board input)
+ const isOutputNode = nodeData.inputs.board !== undefined;
+
+ // Use canvas output prefix for output nodes, otherwise keep original ID
+ const nodeId = isOutputNode ? getPrefixedId(CANVAS_OUTPUT_PREFIX) : nodeData.id;
+ nodeIdMapping[nodeData.id] = nodeId;
+
+ const invocation: Record = {
+ id: nodeId,
+ type: nodeData.type,
+ is_intermediate: nodeData.isIntermediate ?? true,
+ use_cache: nodeData.useCache ?? true,
+ };
+
+ // Add input values to the invocation
+ for (const [fieldName, fieldData] of Object.entries(nodeData.inputs)) {
+ const fieldValue = (fieldData as { value?: unknown }).value;
+ if (fieldValue !== undefined) {
+ invocation[fieldName] = fieldValue;
+ }
+ }
+
+ graphNodes[nodeId] = invocation;
+ }
+
+ // Convert edges to graph format, using the node ID mapping
+ const edgesArray = updatedWorkflow.edges as Array<{
+ source: string;
+ target: string;
+ sourceHandle: string;
+ targetHandle: string;
+ }>;
+ const graphEdges = edgesArray.map((edge) => ({
+ source: {
+ node_id: nodeIdMapping[edge.source] || edge.source,
+ field: edge.sourceHandle,
+ },
+ destination: {
+ node_id: nodeIdMapping[edge.target] || edge.target,
+ field: edge.targetHandle,
+ },
+ }));
+
+ const graph = {
+ id: workflow.workflow.id || workflow.workflow_id || 'temp',
+ nodes: graphNodes,
+ edges: graphEdges,
+ };
+
+ log.debug({ workflowName: workflow.name, destination: canvasSessionId }, 'Enqueueing workflow on canvas');
+
+ await dispatch(
+ queueApi.endpoints.enqueueBatch.initiate({
+ batch: {
+ workflow: updatedWorkflow,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ graph: graph as any,
+ runs: 1,
+ origin: 'canvas_workflow_integration',
+ destination: canvasSessionId,
+ },
+ prepend: true,
+ })
+ ).unwrap();
+
+ // 5. Close the modal and show success message
+ // Results will appear in the staging area where user can accept/discard them
+ toast({
+ status: 'success',
+ title: t('controlLayers.workflowIntegration.executionStarted', 'Workflow execution started'),
+ description: t(
+ 'controlLayers.workflowIntegration.executionStartedDescription',
+ 'The result will appear in the staging area when complete.'
+ ),
+ });
+
+ dispatch(canvasWorkflowIntegrationClosed());
+ } catch (error) {
+ log.error('Error executing workflow');
+ toast({
+ status: 'error',
+ title: t('controlLayers.workflowIntegration.executionFailed', 'Failed to execute workflow'),
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }, [
+ selectedWorkflowId,
+ sourceEntityIdentifier,
+ canvasManager,
+ dispatch,
+ getWorkflow,
+ t,
+ fieldValues,
+ selectedImageFieldKey,
+ canvasSessionId,
+ ]);
+
+ return {
+ execute,
+ canExecute,
+ };
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useFilteredWorkflows.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useFilteredWorkflows.tsx
new file mode 100644
index 00000000000..f28b4a4ee5a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useFilteredWorkflows.tsx
@@ -0,0 +1,107 @@
+import { logger } from 'app/logging/logger';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { useCallback, useEffect, useState } from 'react';
+import { workflowsApi } from 'services/api/endpoints/workflows';
+import type { paths } from 'services/api/schema';
+
+import { workflowHasImageField } from './workflowHasImageField';
+
+const log = logger('canvas-workflow-integration');
+
+type WorkflowListItem =
+ paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json']['items'][number];
+
+interface UseFilteredWorkflowsResult {
+ filteredWorkflows: WorkflowListItem[];
+ isFiltering: boolean;
+}
+
+/**
+ * Hook that filters workflows to only include those with at least one ImageField
+ * @param workflows The list of workflows to filter
+ * @returns Filtered list of workflows that have ImageFields
+ */
+export function useFilteredWorkflows(workflows: WorkflowListItem[]): UseFilteredWorkflowsResult {
+ const dispatch = useAppDispatch();
+ const [filteredWorkflows, setFilteredWorkflows] = useState([]);
+ const [isFiltering, setIsFiltering] = useState(false);
+
+ const filterWorkflows = useCallback(async () => {
+ if (workflows.length === 0) {
+ setFilteredWorkflows([]);
+ return;
+ }
+
+ setIsFiltering(true);
+
+ try {
+ // Load all workflows in parallel and check for ImageFields
+ const workflowChecks = await Promise.all(
+ workflows.map(async (workflow) => {
+ try {
+ // Fetch the full workflow data using dispatch
+ const result = await dispatch(
+ workflowsApi.endpoints.getWorkflow.initiate(workflow.workflow_id, {
+ subscribe: false,
+ forceRefetch: false,
+ })
+ );
+
+ // Get the data from the result
+ const data = 'data' in result ? result.data : undefined;
+
+ const hasImageField = workflowHasImageField(data);
+
+ log.debug(
+ { workflowId: workflow.workflow_id, name: workflow.name, hasImageField },
+ 'Checked workflow for ImageField'
+ );
+
+ // Clean up the subscription
+ if ('unsubscribe' in result && typeof result.unsubscribe === 'function') {
+ result.unsubscribe();
+ }
+
+ return {
+ workflow,
+ hasImageField,
+ };
+ } catch (error) {
+ log.error(
+ {
+ error: error instanceof Error ? error.message : String(error),
+ workflowId: workflow.workflow_id,
+ },
+ 'Error checking workflow for ImageField'
+ );
+ return {
+ workflow,
+ hasImageField: false,
+ };
+ }
+ })
+ );
+
+ // Filter to only include workflows with ImageFields
+ const filtered = workflowChecks.filter((check) => check.hasImageField).map((check) => check.workflow);
+
+ log.debug({ totalWorkflows: workflows.length, filteredCount: filtered.length }, 'Filtered workflows');
+
+ setFilteredWorkflows(filtered);
+ } catch (error) {
+ log.error({ error: error instanceof Error ? error.message : String(error) }, 'Error filtering workflows');
+ setFilteredWorkflows([]);
+ } finally {
+ setIsFiltering(false);
+ }
+ }, [workflows, dispatch]);
+
+ useEffect(() => {
+ filterWorkflows();
+ }, [filterWorkflows]);
+
+ return {
+ filteredWorkflows,
+ isFiltering,
+ };
+}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/workflowHasImageField.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/workflowHasImageField.tsx
new file mode 100644
index 00000000000..beca936867c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/workflowHasImageField.tsx
@@ -0,0 +1,76 @@
+import { logger } from 'app/logging/logger';
+import { $templates } from 'features/nodes/store/nodesSlice';
+import { isNodeFieldElement } from 'features/nodes/types/workflow';
+import type { paths } from 'services/api/schema';
+
+const log = logger('canvas-workflow-integration');
+
+type WorkflowResponse =
+ paths['/api/v1/workflows/i/{workflow_id}']['get']['responses']['200']['content']['application/json'];
+
+/**
+ * Checks if a workflow has at least one ImageField input exposed in the Form Builder.
+ * Only workflows with Form Builder are supported, as they allow users to modify
+ * models and other parameters. Workflows without Form Builder are excluded.
+ * @param workflow The workflow to check
+ * @returns true if the workflow has a Form Builder with at least one ImageField, false otherwise
+ */
+export function workflowHasImageField(workflow: WorkflowResponse | undefined): boolean {
+ if (!workflow?.workflow) {
+ log.debug('No workflow data provided');
+ return false;
+ }
+
+ // Only workflows with Form Builder are supported
+ // Workflows without Form Builder don't allow changing models and other parameters
+ if (!workflow.workflow.form?.elements) {
+ log.debug('Workflow has no form builder - excluding from list');
+ return false;
+ }
+
+ const templates = $templates.get();
+ const elements = workflow.workflow.form.elements;
+
+ log.debug('Workflow has form builder, checking form elements for ImageField');
+
+ for (const [elementId, element] of Object.entries(elements)) {
+ if (isNodeFieldElement(element)) {
+ const { fieldIdentifier } = element.data;
+
+ // Find the node that contains this field
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const node = workflow.workflow.nodes?.find((n: any) => n.data?.id === fieldIdentifier.nodeId);
+ if (!node) {
+ continue;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const nodeType = (node.data as any)?.type;
+ if (!nodeType) {
+ continue;
+ }
+
+ const template = templates[nodeType];
+ if (!template?.inputs) {
+ continue;
+ }
+
+ const fieldTemplate = template.inputs[fieldIdentifier.fieldName];
+ if (!fieldTemplate) {
+ continue;
+ }
+
+ // Check if this is an ImageField
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fieldType = (fieldTemplate as any).type?.name;
+ if (fieldType === 'ImageField') {
+ log.debug({ elementId, fieldName: fieldIdentifier.fieldName }, 'Found ImageField in workflow form');
+ return true;
+ }
+ }
+ }
+
+ // If we have a form but no ImageFields were found in it, return false
+ log.debug('Workflow has form builder but no ImageField found in form elements');
+ return false;
+}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
index 708f7f29cd6..38b25c1b470 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
@@ -6,6 +6,7 @@ import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/c
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown';
+import { CanvasEntityMenuItemsRunWorkflow } from 'features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow';
import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave';
import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSelectObject';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
@@ -24,6 +25,7 @@ export const RasterLayerMenuItems = memo(() => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow.tsx
new file mode 100644
index 00000000000..0264dd23dfb
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow.tsx
@@ -0,0 +1,25 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { canvasWorkflowIntegrationOpened } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFlowArrowBold } from 'react-icons/pi';
+
+export const CanvasEntityMenuItemsRunWorkflow = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const entityIdentifier = useEntityIdentifierContext();
+
+ const onClick = useCallback(() => {
+ dispatch(canvasWorkflowIntegrationOpened({ sourceEntityIdentifier: entityIdentifier }));
+ }, [dispatch, entityIdentifier]);
+
+ return (
+ }>
+ {t('controlLayers.workflowIntegration.runWorkflow', 'Run Workflow')}
+
+ );
+});
+
+CanvasEntityMenuItemsRunWorkflow.displayName = 'CanvasEntityMenuItemsRunWorkflow';
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasWorkflowIntegrationSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasWorkflowIntegrationSlice.ts
new file mode 100644
index 00000000000..0056885f0fa
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasWorkflowIntegrationSlice.ts
@@ -0,0 +1,134 @@
+import type { PayloadAction, Selector } from '@reduxjs/toolkit';
+import { createSelector, createSlice } from '@reduxjs/toolkit';
+import type { RootState } from 'app/store/store';
+import type { SliceConfig } from 'app/store/types';
+import { isPlainObject } from 'es-toolkit';
+import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
+import { assert } from 'tsafe';
+import z from 'zod';
+
+const zCanvasWorkflowIntegrationState = z.object({
+ _version: z.literal(1),
+ isOpen: z.boolean(),
+ selectedWorkflowId: z.string().nullable(),
+ sourceEntityIdentifier: z
+ .object({
+ type: z.enum(['raster_layer', 'control_layer', 'regional_guidance', 'inpaint_mask']),
+ id: z.string(),
+ })
+ .nullable(),
+ fieldValues: z.record(z.string(), z.any()).nullable(),
+ // Which ImageField to use for canvas image (format: "nodeId.fieldName")
+ selectedImageFieldKey: z.string().nullable(),
+ isProcessing: z.boolean(),
+});
+
+type CanvasWorkflowIntegrationState = z.infer;
+
+const getInitialState = (): CanvasWorkflowIntegrationState => ({
+ _version: 1,
+ isOpen: false,
+ selectedWorkflowId: null,
+ sourceEntityIdentifier: null,
+ fieldValues: null,
+ selectedImageFieldKey: null,
+ isProcessing: false,
+});
+
+const slice = createSlice({
+ name: 'canvasWorkflowIntegration',
+ initialState: getInitialState(),
+ reducers: {
+ canvasWorkflowIntegrationOpened: (
+ state,
+ action: PayloadAction<{ sourceEntityIdentifier: CanvasEntityIdentifier }>
+ ) => {
+ state.isOpen = true;
+ state.sourceEntityIdentifier = action.payload.sourceEntityIdentifier;
+ state.selectedWorkflowId = null;
+ state.fieldValues = null;
+ },
+ canvasWorkflowIntegrationClosed: (state) => {
+ state.isOpen = false;
+ state.selectedWorkflowId = null;
+ state.sourceEntityIdentifier = null;
+ state.fieldValues = null;
+ state.selectedImageFieldKey = null;
+ state.isProcessing = false;
+ },
+ canvasWorkflowIntegrationWorkflowSelected: (state, action: PayloadAction<{ workflowId: string | null }>) => {
+ state.selectedWorkflowId = action.payload.workflowId;
+ // Reset field values when switching workflows
+ state.fieldValues = null;
+ state.selectedImageFieldKey = null;
+ },
+ canvasWorkflowIntegrationImageFieldSelected: (state, action: PayloadAction<{ fieldKey: string | null }>) => {
+ state.selectedImageFieldKey = action.payload.fieldKey;
+ },
+ canvasWorkflowIntegrationFieldValueChanged: (
+ state,
+ action: PayloadAction<{ fieldName: string; value: unknown }>
+ ) => {
+ if (!state.fieldValues) {
+ state.fieldValues = {};
+ }
+ state.fieldValues[action.payload.fieldName] = action.payload.value;
+ },
+ canvasWorkflowIntegrationFieldValuesReset: (state) => {
+ state.fieldValues = null;
+ },
+ canvasWorkflowIntegrationProcessingStarted: (state) => {
+ state.isProcessing = true;
+ },
+ canvasWorkflowIntegrationProcessingCompleted: (state) => {
+ state.isProcessing = false;
+ },
+ },
+});
+
+export const {
+ canvasWorkflowIntegrationOpened,
+ canvasWorkflowIntegrationClosed,
+ canvasWorkflowIntegrationWorkflowSelected,
+ canvasWorkflowIntegrationImageFieldSelected,
+ canvasWorkflowIntegrationFieldValueChanged,
+ canvasWorkflowIntegrationProcessingStarted,
+ canvasWorkflowIntegrationProcessingCompleted,
+} = slice.actions;
+
+export const canvasWorkflowIntegrationSliceConfig: SliceConfig = {
+ slice,
+ schema: zCanvasWorkflowIntegrationState,
+ getInitialState,
+ persistConfig: {
+ migrate: (state) => {
+ assert(isPlainObject(state));
+ if (!('_version' in state)) {
+ state._version = 1;
+ }
+ return zCanvasWorkflowIntegrationState.parse(state);
+ },
+ persistDenylist: ['isOpen', 'isProcessing', 'sourceEntityIdentifier'],
+ },
+};
+
+const selectCanvasWorkflowIntegrationSlice = (state: RootState) => state.canvasWorkflowIntegration;
+const createCanvasWorkflowIntegrationSelector = (selector: Selector) =>
+ createSelector(selectCanvasWorkflowIntegrationSlice, selector);
+
+export const selectCanvasWorkflowIntegrationIsOpen = createCanvasWorkflowIntegrationSelector((state) => state.isOpen);
+export const selectCanvasWorkflowIntegrationSelectedWorkflowId = createCanvasWorkflowIntegrationSelector(
+ (state) => state.selectedWorkflowId
+);
+export const selectCanvasWorkflowIntegrationSourceEntityIdentifier = createCanvasWorkflowIntegrationSelector(
+ (state) => state.sourceEntityIdentifier
+);
+export const selectCanvasWorkflowIntegrationFieldValues = createCanvasWorkflowIntegrationSelector(
+ (state) => state.fieldValues
+);
+export const selectCanvasWorkflowIntegrationSelectedImageFieldKey = createCanvasWorkflowIntegrationSelector(
+ (state) => state.selectedImageFieldKey
+);
+export const selectCanvasWorkflowIntegrationIsProcessing = createCanvasWorkflowIntegrationSelector(
+ (state) => state.isProcessing
+);
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
index bbfb82e2e22..d748807d5c7 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
@@ -1,6 +1,8 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { getPrefixedId } from 'features/controlLayers/konva/util';
+
+export { getPrefixedId };
import { selectSaveAllImagesToGallery } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
@@ -205,7 +207,7 @@ export const getInfill = (
assert(false, 'Unknown infill method');
};
-const CANVAS_OUTPUT_PREFIX = 'canvas_output';
+export const CANVAS_OUTPUT_PREFIX = 'canvas_output';
export const isMainModelWithoutUnet = (modelLoader: Invocation) => {
return (
diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
index e2ee74dcad1..14bdf343ec4 100644
--- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
+++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
@@ -1,6 +1,7 @@
import { logger } from 'app/logging/logger';
import type { AppDispatch, AppGetState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
+import { canvasWorkflowIntegrationProcessingCompleted } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
import {
selectAutoSwitch,
selectGalleryView,
@@ -13,8 +14,10 @@ import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks
import { isImageField, isImageFieldCollection } from 'features/nodes/types/common';
import { zNodeStatus } from 'features/nodes/types/invocation';
import type { LRUCache } from 'lru-cache';
+import { LIST_ALL_TAG } from 'services/api';
import { boardsApi } from 'services/api/endpoints/boards';
import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images';
+import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO, S } from 'services/api/types';
import { getCategories } from 'services/api/util';
import { insertImageIntoNamesResult } from 'services/api/util/optimisticUpdates';
@@ -217,6 +220,27 @@ export const buildOnInvocationComplete = (
return imageDTOs;
};
+ const clearCanvasWorkflowIntegrationProcessing = (data: S['InvocationCompleteEvent']) => {
+ // Check if this is a canvas workflow integration result
+ // Results go to staging area automatically via destination = canvasSessionId
+ if (data.origin !== 'canvas_workflow_integration') {
+ return;
+ }
+ // Clear processing state so the modal loading spinner stops
+ dispatch(canvasWorkflowIntegrationProcessingCompleted());
+
+ // Check if this invocation produced an image output
+ const hasImageOutput = objectEntries(data.result).some(([_name, value]) => {
+ return isImageField(value) || isImageFieldCollection(value);
+ });
+
+ // Only invalidate if this invocation produced an image - this ensures the staging area
+ // gets updated immediately when output images are available, without invalidating on every invocation
+ if (hasImageOutput) {
+ dispatch(queueApi.util.invalidateTags([{ type: 'SessionQueueItem', id: LIST_ALL_TAG }]));
+ }
+ };
+
return async (data: S['InvocationCompleteEvent']) => {
if (finishedQueueItemIds.has(data.item_id)) {
log.trace({ data } as JsonObject, `Received event for already-finished queue item ${data.item_id}`);
@@ -236,6 +260,10 @@ export const buildOnInvocationComplete = (
upsertExecutionState(_nodeExecutionState.nodeId, _nodeExecutionState);
}
+ // Clear canvas workflow integration processing state if needed
+ clearCanvasWorkflowIntegrationProcessing(data);
+
+ // Add images to gallery (canvas workflow integration results go to staging area automatically)
await addImagesToGallery(data);
$lastProgressEvent.set(null);