Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
92e51a8
feat: Add canvas-workflow integration feature
claude Nov 12, 2025
e3cfe77
Merge branch 'invoke-ai:main' into claude/canvas-workflow-integration…
Pfannkuchensack Nov 16, 2025
ccacf5d
refactor(ui): simplify canvas workflow integration field rendering
Pfannkuchensack Nov 17, 2025
cf7e6d8
feat(ui): add canvas-workflow-integration logging namespace
Pfannkuchensack Nov 22, 2025
ed1a75e
feat(ui): add workflow filtering for canvas-workflow integration
Pfannkuchensack Nov 23, 2025
062ff6c
feat(ui): add persistence and migration for canvas workflow integrati…
Pfannkuchensack Nov 23, 2025
efe1f72
pnpm fix imports
Pfannkuchensack Nov 23, 2025
3aa9f6e
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Dec 6, 2025
aeeba8a
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
lstein Dec 16, 2025
16f64e9
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
lstein Dec 22, 2025
331c843
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Dec 24, 2025
56ddc62
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Dec 26, 2025
41b672d
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Dec 28, 2025
0acaade
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Jan 7, 2026
30d25b5
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Jan 10, 2026
000a897
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Jan 12, 2026
3e33bcf
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Jan 26, 2026
8a7ce0c
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Feb 10, 2026
e14bd72
fix(ui): handle workflow errors in canvas staging area and improve fo…
Pfannkuchensack Feb 11, 2026
4faf414
feat(ui): add canvas_output node and entry-based staging area
Pfannkuchensack Feb 12, 2026
8e1764e
Chore pnp run fix
Pfannkuchensack Feb 12, 2026
2421a98
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Feb 21, 2026
110d5b4
Chore eslint fix
Pfannkuchensack Feb 25, 2026
0b1f899
Merge remote-tracking branch 'origin/main' into claude/canvas-workflo…
Pfannkuchensack Feb 25, 2026
11e1f95
Remove unused useOutputImageDTO export to fix knip lint
Pfannkuchensack Feb 25, 2026
c8a62fc
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Mar 1, 2026
b5ed942
Update invokeai/frontend/web/src/features/controlLayers/components/Ca…
Pfannkuchensack Mar 2, 2026
d164725
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Mar 2, 2026
5cc3408
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Mar 9, 2026
089f7ee
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Mar 23, 2026
b7530ad
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
Pfannkuchensack Mar 29, 2026
d2507d5
move UI text to en.json
dunkeroni Apr 6, 2026
79caf0a
fix conflicts merge with main
dunkeroni Apr 6, 2026
1132793
generate schema
dunkeroni Apr 6, 2026
f9ab0a6
Merge remote-tracking branch 'upstream/main' into claude/canvas-workf…
Pfannkuchensack Apr 6, 2026
03547b4
Chore typegen
Pfannkuchensack Apr 6, 2026
ef41b33
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
lstein Apr 7, 2026
611d3b1
Merge branch 'main' into claude/canvas-workflow-integration-011CV36r1…
lstein Apr 7, 2026
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
27 changes: 27 additions & 0 deletions invokeai/app/invocations/canvas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.shared.invocation_context import InvocationContext


@invocation(
"canvas_output",
title="Canvas Output",
tags=["canvas", "output", "image"],
category="canvas",
version="1.0.0",
use_cache=False,
)
class CanvasOutputInvocation(BaseInvocation):
"""Outputs an image to the canvas staging area.
Use this node in workflows intended for canvas workflow integration.
Connect the final image of your workflow to this node to send it
to the canvas staging area when run via 'Run Workflow on Canvas'."""

image: ImageField = InputField(description=FieldDescriptions.image)

def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
image_dto = context.images.save(image=image)
return ImageOutput.build(image_dto)
21 changes: 21 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2377,6 +2377,27 @@
"pullBboxIntoReferenceImageError": "Problem Pulling BBox Into ReferenceImage",
"addAdjustments": "Add Adjustments",
"removeAdjustments": "Remove Adjustments",
"workflowIntegration": {
"title": "Run Workflow on Canvas",
"description": "Select a workflow with a Canvas Output node 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.",
"execute": "Execute Workflow",
"executing": "Executing...",
"runWorkflow": "Run Workflow",
"filteringWorkflows": "Filtering workflows...",
"loadingWorkflows": "Loading workflows...",
"noWorkflowsFound": "No workflows found.",
"noWorkflowsWithImageField": "No compatible workflows found. A workflow needs a Form Builder with an image input field and a Canvas Output node.",
"selectWorkflow": "Select Workflow",
"selectPlaceholder": "Choose a workflow...",
"unnamedWorkflow": "Unnamed Workflow",
"loadingParameters": "Loading workflow parameters...",
"noFormBuilderError": "This workflow has no form builder and cannot be used. Please select a different workflow.",
"imageFieldSelected": "This field will receive the canvas image",
"imageFieldNotSelected": "Click to use this field for canvas image",
"executionStarted": "Workflow execution started",
"executionStartedDescription": "The result will appear in the staging area when complete.",
"executionFailed": "Failed to execute workflow"
},
"compositeOperation": {
"label": "Blend Mode",
"add": "Add Blend Mode",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -51,6 +52,7 @@ export const GlobalModalIsolator = memo(() => {
<SaveWorkflowAsDialog />
<CanvasManagerProviderGate>
<CanvasPasteModal />
<CanvasWorkflowIntegrationModal />
</CanvasManagerProviderGate>
<LoadWorkflowFromGraphModal />
<CropImageModal />
Expand Down
1 change: 1 addition & 0 deletions invokeai/frontend/web/src/app/logging/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const $logger = atom<Logger>(Roarr.child(BASE_CONTEXT));

export const zLogNamespace = z.enum([
'canvas',
'canvas-workflow-integration',
'config',
'dnd',
'events',
Expand Down
3 changes: 3 additions & 0 deletions invokeai/frontend/web/src/app/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSe
import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice';
import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { canvasTextSliceConfig } from 'features/controlLayers/store/canvasTextSlice';
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';
Expand Down Expand Up @@ -67,6 +68,7 @@ const SLICE_CONFIGS = {
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
[canvasTextSliceConfig.slice.reducerPath]: canvasTextSliceConfig,
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
[canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig,
Expand Down Expand Up @@ -98,6 +100,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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 (
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Heading size="md">{t('controlLayers.workflowIntegration.title')}</Heading>
</ModalHeader>
<ModalCloseButton isDisabled={isProcessing} />

<ModalBody>
<Flex direction="column" gap={4}>
<Text fontSize="sm" color="base.400">
{t('controlLayers.workflowIntegration.description')}
</Text>

<CanvasWorkflowIntegrationWorkflowSelector />

{selectedWorkflowId && <CanvasWorkflowIntegrationParameterPanel />}
</Flex>
</ModalBody>

<ModalFooter>
<ButtonGroup>
<Button variant="ghost" onClick={onClose} isDisabled={isProcessing}>
{t('common.cancel')}
</Button>
<Spacer />
<Button
onClick={onExecute}
isDisabled={!canExecute || isProcessing}
loadingText={t('controlLayers.workflowIntegration.executing')}
>
{isProcessing && <Spinner size="sm" mr={2} />}
{t('controlLayers.workflowIntegration.execute')}
</Button>
</ButtonGroup>
</ModalFooter>
</ModalContent>
</Modal>
);
});

CanvasWorkflowIntegrationModal.displayName = 'CanvasWorkflowIntegrationModal';
Original file line number Diff line number Diff line change
@@ -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 (
<Box w="full">
<WorkflowFormPreview />
</Box>
);
});

CanvasWorkflowIntegrationParameterPanel.displayName = 'CanvasWorkflowIntegrationParameterPanel';
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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<HTMLSelectElement>) => {
const workflowId = e.target.value || null;
dispatch(canvasWorkflowIntegrationWorkflowSelected({ workflowId }));
},
[dispatch]
);

if (isLoading || isFiltering) {
return (
<Flex alignItems="center" gap={2}>
<Spinner size="sm" />
<Text>
{isFiltering
? t('controlLayers.workflowIntegration.filteringWorkflows')
: t('controlLayers.workflowIntegration.loadingWorkflows')}
</Text>
</Flex>
);
}

if (filteredWorkflows.length === 0) {
return (
<Text color="warning.400" fontSize="sm">
{workflows.length === 0
? t('controlLayers.workflowIntegration.noWorkflowsFound')
: t('controlLayers.workflowIntegration.noWorkflowsWithImageField')}
</Text>
);
}

return (
<FormControl>
<FormLabel>{t('controlLayers.workflowIntegration.selectWorkflow')}</FormLabel>
<Select
placeholder={t('controlLayers.workflowIntegration.selectPlaceholder')}
value={selectedWorkflowId || ''}
onChange={onChange}
>
{filteredWorkflows.map((workflow) => (
<option key={workflow.workflow_id} value={workflow.workflow_id}>
{workflow.name || t('controlLayers.workflowIntegration.unnamedWorkflow')}
</option>
))}
</Select>
</FormControl>
);
});

CanvasWorkflowIntegrationWorkflowSelector.displayName = 'CanvasWorkflowIntegrationWorkflowSelector';
Loading
Loading