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') },
}),
},