From f3aad7a494d4e8c60591dddfd17805cdd3b4a467 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 Aug 2024 22:12:49 +1000 Subject: [PATCH] feat(ui): add merge visible for raster and inpaint mask layers I don't think it makes sense to merge control layers or regional guidance layers because they have additional state. --- invokeai/frontend/web/public/locales/en.json | 3 + .../common/CanvasEntityGroupList.tsx | 9 +- .../common/CanvasEntityMergeVisibleButton.tsx | 88 +++++++++++++++++++ .../konva/CanvasCompositorModule.ts | 22 +++-- .../controlLayers/store/canvasSlice.ts | 49 +++++++++-- 5 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index d35da423bb..343c5e5adf 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1658,6 +1658,9 @@ "saveBboxToGallery": "Save Bbox To Gallery", "savedToGalleryOk": "Saved to Gallery", "savedToGalleryError": "Error saving to gallery", + "mergeVisible": "Merge Visible", + "mergeVisibleOk": "Merged visible layers", + "mergeVisibleError": "Error merging visible layers", "clearHistory": "Clear History", "generateMode": "Generate", "generateModeDesc": "Create individual images. Generated images are added directly to the gallery.", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx index 61b42bd4c4..41f8fc1c2e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx @@ -2,11 +2,12 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library'; import { useBoolean } from 'common/hooks/useBoolean'; import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton'; +import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton'; import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle'; import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { PiCaretDownBold } from 'react-icons/pi'; type Props = PropsWithChildren<{ @@ -21,6 +22,9 @@ const _hover: SystemStyleObject = { export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props) => { const title = useEntityTypeTitle(type); const collapse = useBoolean(true); + const canMergeVisible = useMemo(() => type === 'raster_layer' || type === 'inpaint_mask', [type]); + const canHideAll = useMemo(() => type !== 'ip_adapter', [type]); + return ( @@ -54,8 +58,9 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props + {canMergeVisible && } - {type !== 'ip_adapter' && } + {canHideAll && } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx new file mode 100644 index 0000000000..5615634012 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx @@ -0,0 +1,88 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { logger } from 'app/logging/logger'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { isOk, withResultAsync } from 'common/util/result'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { inpaintMaskAdded, rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { imageDTOToImageObject } from 'features/controlLayers/store/types'; +import { toast } from 'features/toast/toast'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiStackBold } from 'react-icons/pi'; +import { serializeError } from 'serialize-error'; + +const log = logger('canvas'); + +type Props = { + type: CanvasEntityIdentifier['type']; +}; + +export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canvasManager = useCanvasManager(); + const onClick = useCallback(async () => { + if (type === 'raster_layer') { + const rect = canvasManager.stage.getVisibleRect('raster_layer'); + const result = await withResultAsync(() => + canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, false) + ); + + if (isOk(result)) { + dispatch( + rasterLayerAdded({ + isSelected: true, + overrides: { + objects: [imageDTOToImageObject(result.value)], + position: { x: Math.floor(rect.x), y: Math.floor(rect.y) }, + }, + deleteOthers: true, + }) + ); + toast({ title: t('controlLayers.mergeVisibleOk') }); + } else { + log.error({ error: serializeError(result.error) }, 'Failed to merge visible'); + toast({ title: t('controlLayers.mergeVisibleError'), status: 'error' }); + } + } else if (type === 'inpaint_mask') { + const rect = canvasManager.stage.getVisibleRect('inpaint_mask'); + const result = await withResultAsync(() => + canvasManager.compositor.rasterizeAndUploadCompositeInpaintMask(rect, false) + ); + + if (isOk(result)) { + dispatch( + inpaintMaskAdded({ + isSelected: true, + overrides: { + objects: [imageDTOToImageObject(result.value)], + position: { x: Math.floor(rect.x), y: Math.floor(rect.y) }, + }, + deleteOthers: true, + }) + ); + toast({ title: t('controlLayers.mergeVisibleOk') }); + } else { + log.error({ error: serializeError(result.error) }, 'Failed to merge visible'); + toast({ title: t('controlLayers.mergeVisibleError'), status: 'error' }); + } + } else { + log.error({ type }, 'Unsupported type for merge visible'); + } + }, [canvasManager.compositor, canvasManager.stage, dispatch, t, type]); + + return ( + } + onClick={onClick} + alignSelf="stretch" + /> + ); +}); + +CanvasEntityMergeVisibleButton.displayName = 'CanvasEntityMergeVisibleButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index a371c2eff0..69bea521e9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -179,6 +179,18 @@ export class CanvasCompositorModule extends CanvasModuleABC { return imageDTO; }; + rasterizeAndUploadCompositeInpaintMask = async (rect: Rect, saveToGallery: boolean) => { + this.log.trace({ rect }, 'Rasterizing composite inpaint mask'); + + const canvas = this.getCompositeInpaintMaskCanvas(rect); + const blob = await canvasToBlob(canvas); + if (this.manager._isDebugging) { + previewBlob(blob, 'Composite inpaint mask canvas'); + } + + return uploadImage(blob, 'composite-inpaint-mask.png', 'general', !saveToGallery); + }; + getCompositeInpaintMaskImageDTO = async (rect: Rect): Promise => { let imageDTO: ImageDTO | null = null; @@ -193,15 +205,7 @@ export class CanvasCompositorModule extends CanvasModuleABC { } } - this.log.trace({ rect }, 'Rasterizing composite inpaint mask'); - - const canvas = this.getCompositeInpaintMaskCanvas(rect); - const blob = await canvasToBlob(canvas); - if (this.manager._isDebugging) { - previewBlob(blob, 'Composite inpaint mask canvas'); - } - - imageDTO = await uploadImage(blob, 'composite-inpaint-mask.png', 'general', true); + imageDTO = await this.rasterizeAndUploadCompositeInpaintMask(rect, false); this.manager.cache.imageNameCache.set(hash, imageDTO.image_name); return imageDTO; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 805aed3760..155635d1e9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -123,9 +123,14 @@ export const canvasSlice = createSlice({ rasterLayerAdded: { reducer: ( state, - action: PayloadAction<{ id: string; overrides?: Partial; isSelected?: boolean }> + action: PayloadAction<{ + id: string; + overrides?: Partial; + isSelected?: boolean; + deleteOthers?: boolean; + }> ) => { - const { id, overrides, isSelected } = action.payload; + const { id, overrides, isSelected, deleteOthers } = action.payload; const entity: CanvasRasterLayerState = { id, name: null, @@ -137,12 +142,25 @@ export const canvasSlice = createSlice({ position: { x: 0, y: 0 }, }; merge(entity, overrides); - state.rasterLayers.entities.push(entity); + + if (deleteOthers) { + state.rasterLayers.entities = [entity]; + } else { + state.rasterLayers.entities.push(entity); + } + if (isSelected) { state.selectedEntityIdentifier = getEntityIdentifier(entity); } }, - prepare: (payload: { overrides?: Partial; isSelected?: boolean }) => ({ + prepare: (payload: { + overrides?: Partial; + isSelected?: boolean; + /** + * asdf + */ + deleteOthers?: boolean; + }) => ({ payload: { ...payload, id: getPrefixedId('raster_layer') }, }), }, @@ -603,9 +621,14 @@ export const canvasSlice = createSlice({ inpaintMaskAdded: { reducer: ( state, - action: PayloadAction<{ id: string; overrides?: Partial; isSelected?: boolean }> + action: PayloadAction<{ + id: string; + overrides?: Partial; + isSelected?: boolean; + deleteOthers?: boolean; + }> ) => { - const { id, overrides, isSelected } = action.payload; + const { id, overrides, isSelected, deleteOthers } = action.payload; const entity: CanvasInpaintMaskState = { id, name: null, @@ -621,12 +644,22 @@ export const canvasSlice = createSlice({ }, }; merge(entity, overrides); - state.inpaintMasks.entities.push(entity); + + if (deleteOthers) { + state.inpaintMasks.entities = [entity]; + } else { + state.inpaintMasks.entities.push(entity); + } + if (isSelected) { state.selectedEntityIdentifier = getEntityIdentifier(entity); } }, - prepare: (payload?: { overrides?: Partial; isSelected?: boolean }) => ({ + prepare: (payload?: { + overrides?: Partial; + isSelected?: boolean; + deleteOthers?: boolean; + }) => ({ payload: { ...payload, id: getPrefixedId('inpaint_mask') }, }), },