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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't working quite right. The nodes slice always handles the actions even if this listener triggers and dispatches the canvas-specific action also.

For example, if I change the field in the workflow editor, it also changes it in the canvas workflow.

There's an "action router" pattern I've been exploring in #8553 that I think could solve this. The idea is to inject metadata into actions and use it to decide who handles the action. In that PR, the metadata is the canvas ID. For this PR, the metadata would be a flag that says "Is this action for the node editor workflow, or canvas workflow?". Roughed out:

// flag injection/matching utilities, use symbol to prevent collisions
const CANVAS_WORKFLOW_KEY = Symbol('CANVAS_WORKFLOW_KEY');
const NODES_WORKFLOW_KEY = Symbol('NODES_WORKFLOW_KEY');

// call this on the action before dispatching if we are in the canvas workflow, this will go in the useDispatch wrapper below
const injectCanvasWorkflowKey = (action: UnknownAction) => {
  Object.assign(action, { meta: { [CANVAS_WORKFLOW_KEY]: true } }); // could be a canvas id instead of boolean
};

// call this on the action before dispatching if we are in node editor
const injectNodesWorkflowKey = (action: UnknownAction) => {
  Object.assign(action, { meta: { [NODES_WORKFLOW_KEY]: true } });
};

// type guards
const isCanvasWorkflowAction = (action: UnknownAction) => {
  return isPlainObject(action?.meta) && action?.meta?.[CANVAS_WORKFLOW_KEY] === true;
};

const isNodesWorkflowAction = (action: UnknownAction) => {
  return isPlainObject(action?.meta) && action?.meta?.[NODES_WORKFLOW_KEY] === true;
};

// cut and paste all the feild reducers from nodesSlice to this new slice
// but this slice doesn't get added to the store, we just use it as a convenient way to define the reducers
const fieldSlice__DO_NOT_ADD_TO_STORE = createSlice({
  name: 'fields',
  initialState: getInitialState(),
  reducers: {
    fieldValueReset: (state, action: FieldValueAction<StatefulFieldValue>) => {
      fieldValueReducer(state, action, zStatefulFieldValue);
    },
    fieldStringValueChanged: (state, action: FieldValueAction<StringFieldValue>) => {
      fieldValueReducer(state, action, zStringFieldValue);
    },
    //... rest of field reducers
  },
});

// this will match any field action, regardless of its flag
const isFieldAction = isAnyOf(...Object.values(fieldSlice__DO_NOT_ADD_TO_STORE.actions).map((a) => a.match))

Then in nodesSlice and canvasWorkflowSlice we can do this:

const nodesSlice = createSlice({
  // ...
    extraReducers: (builder) => {
    builder.addMatcher(
      isFieldAction,
      (state, action) => {
        if (!isNodesWorkflowAction(action)) {
          return;
        }

        fieldSlice__DO_NOT_ADD_TO_STORE.reducer(state, action);
      }
    );
  },
})

const canvasWorkflowSlice = createSlice({
  // ...
    extraReducers: (builder) => {
    builder.addMatcher(
      isFieldAction,
      (state, action) => {
        if (!isCanvasWorkflowAction(action)) {
          return;
        }

        fieldSlice__DO_NOT_ADD_TO_STORE.reducer(state, action);
      }
    );
  },
})

Wrap useAppDispatch to automatically inject the flags:

export const useAppDispatch = () => {
  const isCanvasWorkflow = useCanvasWorkflowContext() // bool | null
  const isNodesWorkflow = useNodesWorkflowContext() // bool | null
  
  const dispatch = useDispatch();
  
  return useCallback((action) => {
    if (isCanvasWorkflow && isNodesWorkflow) {
      throw new Error('A component cannot be in both a canvas workflow and a nodes workflow');
    }
    
    if (isCanvasWorkflow) {
      injectCanvasWorkflowKey(action);
    } else if (isNodesWorkflow) {
      injectNodesWorkflowKey(action);
    }
    
    return dispatch(action);
    
  }, [dispatch, isCanvasWorkflow, isNodesWorkflow])
}

This pattern is similar to what I'm proposing in #8553 . Plays nicely with it and a future where we have multiple workflow editor instances

Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ActionCreatorWithPayload } from '@reduxjs/toolkit';
import type { AppStartListening, RootState } from 'app/store/store';
import * as canvasWorkflowNodesActions from 'features/controlLayers/store/canvasWorkflowNodesSlice';
import * as nodesActions from 'features/nodes/store/nodesSlice';
import type { AnyNode } from 'features/nodes/types/invocation';

/**
* Listens for field value changes on nodes and redirects them to the canvas workflow nodes slice
* if the node belongs to a canvas workflow.
*/
export const addCanvasWorkflowFieldChangedListener = (startListening: AppStartListening) => {
// List of all field mutation actions from nodesSlice with their canvas workflow counterparts
const fieldMutationActionPairs: Array<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
nodesAction: ActionCreatorWithPayload<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
canvasAction: ActionCreatorWithPayload<any>;
}> = [
{
nodesAction: nodesActions.fieldStringValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldStringValueChanged,
},
{
nodesAction: nodesActions.fieldIntegerValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldIntegerValueChanged,
},
{
nodesAction: nodesActions.fieldFloatValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldFloatValueChanged,
},
{
nodesAction: nodesActions.fieldBooleanValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldBooleanValueChanged,
},
{
nodesAction: nodesActions.fieldModelIdentifierValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldModelIdentifierValueChanged,
},
{
nodesAction: nodesActions.fieldEnumModelValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldEnumModelValueChanged,
},
{
nodesAction: nodesActions.fieldSchedulerValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldSchedulerValueChanged,
},
{
nodesAction: nodesActions.fieldBoardValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldBoardValueChanged,
},
{
nodesAction: nodesActions.fieldImageValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldImageValueChanged,
},
{
nodesAction: nodesActions.fieldColorValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldColorValueChanged,
},
{
nodesAction: nodesActions.fieldImageCollectionValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldImageCollectionValueChanged,
},
{
nodesAction: nodesActions.fieldStringCollectionValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldStringCollectionValueChanged,
},
{
nodesAction: nodesActions.fieldIntegerCollectionValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldIntegerCollectionValueChanged,
},
{
nodesAction: nodesActions.fieldFloatCollectionValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldFloatCollectionValueChanged,
},
{
nodesAction: nodesActions.fieldFloatGeneratorValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldFloatGeneratorValueChanged,
},
{
nodesAction: nodesActions.fieldIntegerGeneratorValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldIntegerGeneratorValueChanged,
},
{
nodesAction: nodesActions.fieldStringGeneratorValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldStringGeneratorValueChanged,
},
{
nodesAction: nodesActions.fieldImageGeneratorValueChanged,
canvasAction: canvasWorkflowNodesActions.fieldImageGeneratorValueChanged,
},
{ nodesAction: nodesActions.fieldValueReset, canvasAction: canvasWorkflowNodesActions.fieldValueReset },
];

for (const { nodesAction, canvasAction } of fieldMutationActionPairs) {
startListening({
actionCreator: nodesAction,
effect: (action, { dispatch, getState }) => {
const state = getState() as RootState;
const { nodeId } = action.payload;

// Check if this node exists in canvas workflow nodes
const canvasWorkflowNode = state.canvasWorkflowNodes.nodes.find((n: AnyNode) => n.id === nodeId);

// If the node exists in canvas workflow, redirect the action
// This ensures canvas workflow fields always update the canvas workflow nodes slice
if (canvasWorkflowNode) {
dispatch(canvasAction(action.payload));
}
},
});
}
};
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,
},
});
}
},
});
};
12 changes: 12 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,8 @@ 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 { addCanvasWorkflowFieldChangedListener } from './middleware/listenerMiddleware/listeners/canvasWorkflowFieldChanged';
import { addCanvasWorkflowRehydratedListener } from './middleware/listenerMiddleware/listeners/canvasWorkflowRehydrated';
import { addImageUploadedFulfilledListener } from './middleware/listenerMiddleware/listeners/imageUploaded';

export const listenerMiddleware = createListenerMiddleware();
Expand All @@ -65,6 +69,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 +97,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 +297,7 @@ addAppConfigReceivedListener(startAppListening);
addAdHocPostProcessingRequestedListener(startAppListening);

addSetDefaultSettingsListener(startAppListening);

// Canvas workflow fields
addCanvasWorkflowFieldChangedListener(startAppListening);
addCanvasWorkflowRehydratedListener(startAppListening);
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useAppSelector } from 'app/store/storeHooks';
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);

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

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

return <CanvasWorkflowElementContext.Provider value={value}>{children}</CanvasWorkflowElementContext.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,42 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
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 (
<CanvasWorkflowModeProvider>
<Flex w="full" justifyContent="center" p={4}>
<CanvasWorkflowRootContainer />
</Flex>
</CanvasWorkflowModeProvider>
);
});
CanvasWorkflowFieldsPanel.displayName = 'CanvasWorkflowFieldsPanel';
Loading