Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions invokeai/app/invocations/canvas_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Canvas workflow bridge invocations."""

from invokeai.app.invocations.baseinvocation import (
BaseInvocation,
Classification,
invocation,
)
from invokeai.app.invocations.fields import ImageField, Input, InputField, WithBoard, WithMetadata
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.shared.invocation_context import InvocationContext


@invocation(
"canvas_composite_raster_input",
title="Canvas Composite Input",
tags=["canvas", "workflow", "canvas-workflow-input"],
category="canvas",
version="1.0.0",
classification=Classification.Beta,
)
class CanvasCompositeRasterInputInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Provides the flattened canvas raster layer to a workflow."""

image: ImageField = InputField(
description="The flattened canvas raster layer.",
input=Input.Direct,
)

def invoke(self, context: InvocationContext) -> ImageOutput:
image_dto = context.images.get_dto(self.image.image_name)
return ImageOutput.build(image_dto=image_dto)


@invocation(
"canvas_workflow_output",
title="Canvas Workflow Output",
tags=["canvas", "workflow", "canvas-workflow-output"],
category="canvas",
version="1.0.0",
classification=Classification.Beta,
)
class CanvasWorkflowOutputInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Designates the workflow image output used by the canvas."""

image: ImageField = InputField(
description="The workflow's resulting image.",
input=Input.Connection,
)

def invoke(self, context: InvocationContext) -> ImageOutput:
image_dto = context.images.get_dto(self.image.image_name)
return ImageOutput.build(image_dto=image_dto)
10 changes: 10 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,16 @@
"recalculateRects": "Recalculate Rects",
"clipToBbox": "Clip Strokes to Bbox",
"outputOnlyMaskedRegions": "Output Only Generated Regions",
"canvasWorkflowLabel": "Canvas Workflow",
"canvasWorkflowInstructions": "Select a workflow containing the canvas composite input and canvas workflow output nodes to drive custom canvas generation.",
"canvasWorkflowSelectedDescription": "This workflow is currently configured for canvas generation.",
"canvasWorkflowSelectButton": "Select Workflow",
"canvasWorkflowSelected": "Canvas workflow selected",
"canvasWorkflowModalTitle": "Select Canvas Workflow",
"canvasWorkflowModalDescription": "Choose a workflow containing the canvas composite input and canvas workflow output nodes. Only workflows that meet these requirements can be used from the canvas.",
"selectCanvasWorkflowTooltip": "Select a workflow to run from the canvas",
"changeCanvasWorkflowTooltip": "Change canvas workflow",
"canvasWorkflowChangeButton": "Change Workflow",
"addLayer": "Add Layer",
"duplicate": "Duplicate",
"moveToFront": "Move to Front",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { AppStartListening } from 'app/store/store';
import { selectCanvasWorkflow } from 'features/controlLayers/store/canvasWorkflowSlice';
import { REMEMBER_REHYDRATED } from 'redux-remember';

/**
* When the app rehydrates from storage, we need to populate the canvasWorkflowNodes
* shadow slice if a canvas workflow was previously selected.
*
* This ensures that exposed fields are visible when the page loads with a workflow already selected.
*/
export const addCanvasWorkflowRehydratedListener = (startListening: AppStartListening) => {
startListening({
type: REMEMBER_REHYDRATED,
effect: (_action, { dispatch, getState }) => {
const state = getState();
const { workflow, inputNodeId } = state.canvasWorkflow;

// If there's a canvas workflow already selected, we need to load it into shadow nodes
if (workflow && inputNodeId) {
// Manually dispatch the fulfilled action to populate shadow nodes
// We can't use the thunk because the workflow is already loaded
dispatch({
type: selectCanvasWorkflow.fulfilled.type,
payload: {
workflow,
inputNodeId,
outputNodeId: state.canvasWorkflow.outputNodeId,
workflowId: state.canvasWorkflow.selectedWorkflowId,
fieldValues: state.canvasWorkflow.fieldValues,
},
});
}
},
});
};
10 changes: 10 additions & 0 deletions invokeai/frontend/web/src/app/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ 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 { canvasWorkflowNodesSliceConfig } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
import { canvasWorkflowSliceConfig } from 'features/controlLayers/store/canvasWorkflowSlice';
import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice';
import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice';
import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice';
Expand Down Expand Up @@ -55,6 +57,7 @@ import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
import { addArchivedOrDeletedBoardListener } from './middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener';
import { addCanvasWorkflowRehydratedListener } from './middleware/listenerMiddleware/listeners/canvasWorkflowRehydrated';
import { addImageUploadedFulfilledListener } from './middleware/listenerMiddleware/listeners/imageUploaded';

export const listenerMiddleware = createListenerMiddleware();
Expand All @@ -65,6 +68,8 @@ const log = logger('system');
const SLICE_CONFIGS = {
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
[canvasWorkflowSliceConfig.slice.reducerPath]: canvasWorkflowSliceConfig,
[canvasWorkflowNodesSliceConfig.slice.reducerPath]: canvasWorkflowNodesSliceConfig,
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
[configSliceConfig.slice.reducerPath]: configSliceConfig,
Expand All @@ -91,6 +96,8 @@ const ALL_REDUCERS = {
[api.reducerPath]: api.reducer,
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig.slice.reducer,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig.slice.reducer,
[canvasWorkflowSliceConfig.slice.reducerPath]: canvasWorkflowSliceConfig.slice.reducer,
[canvasWorkflowNodesSliceConfig.slice.reducerPath]: canvasWorkflowNodesSliceConfig.slice.reducer,
// Undoable!
[canvasSliceConfig.slice.reducerPath]: undoable(
canvasSliceConfig.slice.reducer,
Expand Down Expand Up @@ -289,3 +296,6 @@ addAppConfigReceivedListener(startAppListening);
addAdHocPostProcessingRequestedListener(startAppListening);

addSetDefaultSettingsListener(startAppListening);

// Canvas workflow fields
addCanvasWorkflowRehydratedListener(startAppListening);
25 changes: 24 additions & 1 deletion invokeai/frontend/web/src/app/store/storeHooks.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import type { AppStore, AppThunkDispatch, RootState } from 'app/store/store';
import { useIsCanvasWorkflow } from 'app/store/workflowContext';
import { injectCanvasWorkflowKey, injectNodesWorkflowKey } from 'features/nodes/store/actionRouter';
import { useCallback } from 'react';
import type { TypedUseSelectorHook } from 'react-redux';
import { useDispatch, useSelector, useStore } from 'react-redux';

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppThunkDispatch>();
export const useAppDispatch = (): AppThunkDispatch => {
const isCanvasWorkflow = useIsCanvasWorkflow();
const dispatch = useDispatch<AppThunkDispatch>();

return useCallback(
((action: Parameters<AppThunkDispatch>[0]) => {
// Inject workflow routing metadata into actions
if (typeof action === 'object' && action !== null && 'type' in action) {
if (isCanvasWorkflow) {
injectCanvasWorkflowKey(action);
} else {
injectNodesWorkflowKey(action);
}
}

return dispatch(action);
}) as AppThunkDispatch,
[dispatch, isCanvasWorkflow]
);
};

export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppStore = () => useStore.withTypes<AppStore>()();
15 changes: 15 additions & 0 deletions invokeai/frontend/web/src/app/store/workflowContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';

/**
* Context to track whether we're in a canvas workflow or nodes workflow.
* This is used by the useAppDispatch hook to inject the appropriate action routing metadata.
*/
export const WorkflowContext = createContext<{ isCanvasWorkflow: boolean } | null>(null);

/**
* Hook to check if we're in a canvas workflow context.
*/
export const useIsCanvasWorkflow = (): boolean => {
const context = useContext(WorkflowContext);
return context?.isCanvasWorkflow ?? false;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useAppSelector } from 'app/store/storeHooks';
import { WorkflowContext } from 'app/store/workflowContext';
import { selectCanvasWorkflowNodesSlice } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
import type { FormElement } from 'features/nodes/types/workflow';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useContext, useMemo } from 'react';

/**
* Context that provides element lookup from canvas workflow nodes instead of regular nodes.
* This ensures that when viewing canvas workflow fields, we read from the shadow slice.
*/

type CanvasWorkflowElementContextValue = {
getElement: (id: string) => FormElement | undefined;
};

const CanvasWorkflowElementContext = createContext<CanvasWorkflowElementContextValue | null>(null);

export const CanvasWorkflowElementProvider = memo(({ children }: PropsWithChildren) => {
const nodesState = useAppSelector(selectCanvasWorkflowNodesSlice);

const elementValue = useMemo<CanvasWorkflowElementContextValue>(
() => ({
getElement: (id: string) => nodesState.form.elements[id],
}),
[nodesState.form.elements]
);

const workflowValue = useMemo(() => ({ isCanvasWorkflow: true }), []);

return (
<WorkflowContext.Provider value={workflowValue}>
<CanvasWorkflowElementContext.Provider value={elementValue}>{children}</CanvasWorkflowElementContext.Provider>
</WorkflowContext.Provider>
);
});
CanvasWorkflowElementProvider.displayName = 'CanvasWorkflowElementProvider';

/**
* Hook to get an element, using canvas workflow context if available,
* otherwise falls back to regular nodes.
*/
export const useCanvasWorkflowElement = (): ((id: string) => FormElement | undefined) | null => {
return useContext(CanvasWorkflowElementContext)?.getElement ?? null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasWorkflowElementProvider } from 'features/controlLayers/components/CanvasWorkflowElementContext';
import { CanvasWorkflowModeProvider } from 'features/controlLayers/components/CanvasWorkflowModeContext';
import { CanvasWorkflowRootContainer } from 'features/controlLayers/components/CanvasWorkflowRootContainer';
import { selectCanvasWorkflowNodesSlice } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
import { memo } from 'react';

/**
* Renders the exposed fields for a canvas workflow.
*
* This component renders the workflow's form in view mode.
* Each field element is wrapped with the appropriate InvocationNodeContext
* in CanvasWorkflowFormElementComponent.
*/
export const CanvasWorkflowFieldsPanel = memo(() => {
const nodesState = useAppSelector(selectCanvasWorkflowNodesSlice);

// Check if form is empty
const rootElement = nodesState.form.elements[nodesState.form.rootElementId];
if (
!rootElement ||
!('data' in rootElement) ||
!rootElement.data ||
!('children' in rootElement.data) ||
rootElement.data.children.length === 0
) {
return (
<Flex w="full" p={4} justifyContent="center">
<Text variant="subtext">No fields exposed in this workflow</Text>
</Flex>
);
}

return (
<CanvasWorkflowElementProvider>
<CanvasWorkflowModeProvider>
<Flex w="full" justifyContent="center" p={4}>
<CanvasWorkflowRootContainer />
</Flex>
</CanvasWorkflowModeProvider>
</CanvasWorkflowElementProvider>
);
});
CanvasWorkflowFieldsPanel.displayName = 'CanvasWorkflowFieldsPanel';
Loading