diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 5f8e2070c37..2964917c3ab 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -599,6 +599,14 @@
"toggleNonRasterLayers": {
"title": "Toggle Non-Raster Layers",
"desc": "Show or hide all non-raster layer categories (Control Layers, Inpaint Masks, Regional Guidance)."
+ },
+ "invertMask": {
+ "title": "Invert Mask",
+ "desc": "Invert the selected inpaint mask. Only works when an inpaint mask is selected and has objects."
+ },
+ "adjustBbox": {
+ "title": "Adjust Bbox to Masks",
+ "desc": "Adjust the bounding box to fit all visible inpaint masks with padding."
}
},
"workflows": {
@@ -2093,6 +2101,8 @@
"uploadOrDragAnImage": "Drag an image from the gallery or upload an image.",
"imageNoise": "Image Noise",
"denoiseLimit": "Denoise Limit",
+ "adjustBboxToMasks": "Adjust Bbox to Masks",
+ "invertMask": "Invert Mask",
"warnings": {
"problemsFound": "Problems found",
"unsupportedModel": "layer not supported for selected base model",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx
index 4e1f5c1dc4a..3410f0cce70 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx
@@ -10,6 +10,7 @@ import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScro
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 { InpaintMaskAdjustBboxButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton';
import { RasterLayerExportPSDButton } from 'features/controlLayers/components/RasterLayer/RasterLayerExportPSDButton';
import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover';
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
@@ -165,6 +166,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
+ {type === 'inpaint_mask' && }
{type === 'raster_layer' && }
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx
new file mode 100644
index 00000000000..05f4df1adc4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx
@@ -0,0 +1,116 @@
+import { IconButton, Tooltip } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
+import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
+import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
+import type { Rect } from 'features/controlLayers/store/types';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCropBold } from 'react-icons/pi';
+
+export const InpaintMaskAdjustBboxButton = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const canvasSlice = useAppSelector(selectCanvasSlice);
+ const maskBlur = useAppSelector(selectMaskBlur);
+ const inpaintMasks = canvasSlice.inpaintMasks.entities;
+
+ // Calculate the bounding box that contains all inpaint masks
+ const calculateMaskBbox = useCallback((): Rect | null => {
+ if (inpaintMasks.length === 0) {
+ return null;
+ }
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const mask of inpaintMasks) {
+ if (!mask.isEnabled || mask.objects.length === 0) {
+ continue;
+ }
+ for (const obj of mask.objects) {
+ let objMinX = 0;
+ let objMinY = 0;
+ let objMaxX = 0;
+ let objMaxY = 0;
+ if (obj.type === 'rect') {
+ objMinX = mask.position.x + obj.rect.x;
+ objMinY = mask.position.y + obj.rect.y;
+ objMaxX = objMinX + obj.rect.width;
+ objMaxY = objMinY + obj.rect.height;
+ } else if (
+ obj.type === 'brush_line' ||
+ obj.type === 'brush_line_with_pressure' ||
+ obj.type === 'eraser_line' ||
+ obj.type === 'eraser_line_with_pressure'
+ ) {
+ for (let i = 0; i < obj.points.length; i += 2) {
+ const x = mask.position.x + (obj.points[i] ?? 0);
+ const y = mask.position.y + (obj.points[i + 1] ?? 0);
+ if (i === 0) {
+ objMinX = objMaxX = x;
+ objMinY = objMaxY = y;
+ } else {
+ objMinX = Math.min(objMinX, x);
+ objMinY = Math.min(objMinY, y);
+ objMaxX = Math.max(objMaxX, x);
+ objMaxY = Math.max(objMaxY, y);
+ }
+ }
+ const strokeRadius = (obj.strokeWidth ?? 50) / 2;
+ objMinX -= strokeRadius;
+ objMinY -= strokeRadius;
+ objMaxX += strokeRadius;
+ objMaxY += strokeRadius;
+ } else if (obj.type === 'image') {
+ objMinX = mask.position.x;
+ objMinY = mask.position.y;
+ objMaxX = objMinX + obj.image.width;
+ objMaxY = objMinY + obj.image.height;
+ }
+ minX = Math.min(minX, objMinX);
+ minY = Math.min(minY, objMinY);
+ maxX = Math.max(maxX, objMaxX);
+ maxY = Math.max(maxY, objMaxY);
+ }
+ }
+ if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
+ return null;
+ }
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
+ }, [inpaintMasks]);
+
+ const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]);
+ const handleAdjustBbox = useCallback(() => {
+ if (!maskBbox) {
+ return;
+ }
+ const padding = maskBlur + 8;
+ const adjustedBbox: Rect = {
+ x: maskBbox.x - padding,
+ y: maskBbox.y - padding,
+ width: maskBbox.width + padding * 2,
+ height: maskBbox.height + padding * 2,
+ };
+ dispatch(bboxChangedFromCanvas(adjustedBbox));
+ }, [dispatch, maskBbox, maskBlur]);
+
+ const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0);
+ if (!hasValidMasks) {
+ return null;
+ }
+
+ return (
+
+ }
+ size="sm"
+ variant="ghost"
+ onClick={handleAdjustBbox}
+ />
+
+ );
+});
+
+InpaintMaskAdjustBboxButton.displayName = 'InpaintMaskAdjustBboxButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx
new file mode 100644
index 00000000000..887543c2dce
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx
@@ -0,0 +1,173 @@
+import { Button, Flex, Icon, Text } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
+import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
+import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
+import type {
+ CanvasBrushLineState,
+ CanvasBrushLineWithPressureState,
+ CanvasEraserLineState,
+ CanvasEraserLineWithPressureState,
+ CanvasImageState,
+ CanvasRectState,
+ Rect,
+} from 'features/controlLayers/store/types';
+import { maskObjectsToBitmap } from 'features/controlLayers/util/bitmapToMaskObjects';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCropBold } from 'react-icons/pi';
+
+export const InpaintMaskBboxAdjuster = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const canvasSlice = useAppSelector(selectCanvasSlice);
+ const maskBlur = useAppSelector(selectMaskBlur);
+
+ const inpaintMasks = canvasSlice.inpaintMasks.entities;
+ const bboxRect = canvasSlice.bbox.rect;
+
+ // Calculate the bounding box that contains all inpaint masks
+ const calculateMaskBbox = useCallback((): Rect | null => {
+ if (inpaintMasks.length === 0) {
+ return null;
+ }
+
+ const canvasWidth = bboxRect.width;
+ const canvasHeight = bboxRect.height;
+
+ const allObjects: (
+ | CanvasBrushLineState
+ | CanvasBrushLineWithPressureState
+ | CanvasEraserLineState
+ | CanvasEraserLineWithPressureState
+ | CanvasRectState
+ | CanvasImageState
+ )[] = [];
+ for (const mask of inpaintMasks) {
+ if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) {
+ continue;
+ }
+
+ for (const obj of mask.objects) {
+ if (obj.type === 'rect') {
+ const adjustedObj = {
+ ...obj,
+ rect: {
+ ...obj.rect,
+ x: obj.rect.x + mask.position.x - bboxRect.x,
+ y: obj.rect.y + mask.position.y - bboxRect.y,
+ },
+ };
+ allObjects.push(adjustedObj);
+ } else if (
+ obj.type === 'brush_line' ||
+ obj.type === 'brush_line_with_pressure' ||
+ obj.type === 'eraser_line' ||
+ obj.type === 'eraser_line_with_pressure'
+ ) {
+ const adjustedPoints: number[] = [];
+ for (let i = 0; i < obj.points.length; i += 2) {
+ adjustedPoints.push((obj.points[i] ?? 0) + mask.position.x - bboxRect.x);
+ adjustedPoints.push((obj.points[i + 1] ?? 0) + mask.position.y - bboxRect.y);
+ }
+ const adjustedObj = {
+ ...obj,
+ points: adjustedPoints,
+ };
+ allObjects.push(adjustedObj);
+ } else if (obj.type === 'image') {
+ // For image objects, we need to handle them differently since they don't have rect or points
+ continue;
+ }
+ }
+ }
+
+ if (allObjects.length === 0) {
+ return null;
+ }
+
+ // Render the consolidated mask to a bitmap
+ const bitmap = maskObjectsToBitmap(allObjects, canvasWidth, canvasHeight);
+ const { width, height, data } = bitmap;
+
+ let maskMinX = width;
+ let maskMinY = height;
+ let maskMaxX = 0;
+ let maskMaxY = 0;
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const pixelIndex = (y * width + x) * 4;
+ const alpha = data[pixelIndex + 3] ?? 0;
+
+ if (alpha > 0) {
+ maskMinX = Math.min(maskMinX, x);
+ maskMinY = Math.min(maskMinY, y);
+ maskMaxX = Math.max(maskMaxX, x);
+ maskMaxY = Math.max(maskMaxY, y);
+ }
+ }
+ }
+
+ if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) {
+ return null;
+ }
+
+ maskMinX = Math.max(0, maskMinX);
+ maskMinY = Math.max(0, maskMinY);
+ maskMaxX = Math.min(width - 1, maskMaxX);
+ maskMaxY = Math.min(height - 1, maskMaxY);
+
+ return {
+ x: bboxRect.x + maskMinX,
+ y: bboxRect.y + maskMinY,
+ width: maskMaxX - maskMinX + 1,
+ height: maskMaxY - maskMinY + 1,
+ };
+ }, [inpaintMasks, bboxRect]);
+
+ const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]);
+
+ const handleAdjustBbox = useCallback(() => {
+ if (!maskBbox) {
+ return;
+ }
+
+ // Add padding based on maskblur setting + 8px
+ const padding = maskBlur + 8;
+ const adjustedBbox: Rect = {
+ x: maskBbox.x - padding,
+ y: maskBbox.y - padding,
+ width: maskBbox.width + padding * 2,
+ height: maskBbox.height + padding * 2,
+ };
+
+ dispatch(bboxChangedFromCanvas(adjustedBbox));
+ }, [dispatch, maskBbox, maskBlur]);
+
+ const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0);
+ if (!hasValidMasks) {
+ return null;
+ }
+
+ return (
+
+ }
+ onClick={handleAdjustBbox}
+ flex={1}
+ justifyContent="flex-start"
+ h={6}
+ fontSize="xs"
+ >
+
+ {t('controlLayers.adjustBboxToMasks')}
+
+
+
+ );
+});
+
+InpaintMaskBboxAdjuster.displayName = 'InpaintMaskBboxAdjuster';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx
index 8bbb49a9865..cb8cc4eeca6 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx
@@ -3,6 +3,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask';
+// import { InpaintMaskBboxAdjuster } from 'features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
index 0d0289adf87..60aba9819e8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
@@ -8,8 +8,10 @@ import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/component
import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers';
+import { InpaintMaskMenuItemsAdjustBbox } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox';
import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu';
import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu';
+import { InpaintMaskMenuItemsInvert } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert';
import { memo } from 'react';
export const InpaintMaskMenuItems = memo(() => {
@@ -21,6 +23,8 @@ export const InpaintMaskMenuItems = memo(() => {
+
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx
new file mode 100644
index 00000000000..2750e209c4a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx
@@ -0,0 +1,110 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
+import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
+import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
+import type { Rect } from 'features/controlLayers/store/types';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCropBold } from 'react-icons/pi';
+
+export const InpaintMaskMenuItemsAdjustBbox = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const canvasSlice = useAppSelector(selectCanvasSlice);
+ const maskBlur = useAppSelector(selectMaskBlur);
+ const inpaintMasks = canvasSlice.inpaintMasks.entities;
+
+ // Calculate the bounding box that contains all inpaint masks
+ const calculateMaskBbox = useCallback((): Rect | null => {
+ if (inpaintMasks.length === 0) {
+ return null;
+ }
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const mask of inpaintMasks) {
+ if (!mask.isEnabled || mask.objects.length === 0) {
+ continue;
+ }
+ for (const obj of mask.objects) {
+ let objMinX = 0;
+ let objMinY = 0;
+ let objMaxX = 0;
+ let objMaxY = 0;
+ if (obj.type === 'rect') {
+ objMinX = mask.position.x + obj.rect.x;
+ objMinY = mask.position.y + obj.rect.y;
+ objMaxX = objMinX + obj.rect.width;
+ objMaxY = objMinY + obj.rect.height;
+ } else if (
+ obj.type === 'brush_line' ||
+ obj.type === 'brush_line_with_pressure' ||
+ obj.type === 'eraser_line' ||
+ obj.type === 'eraser_line_with_pressure'
+ ) {
+ for (let i = 0; i < obj.points.length; i += 2) {
+ const x = mask.position.x + (obj.points[i] ?? 0);
+ const y = mask.position.y + (obj.points[i + 1] ?? 0);
+ if (i === 0) {
+ objMinX = objMaxX = x;
+ objMinY = objMaxY = y;
+ } else {
+ objMinX = Math.min(objMinX, x);
+ objMinY = Math.min(objMinY, y);
+ objMaxX = Math.max(objMaxX, x);
+ objMaxY = Math.max(objMaxY, y);
+ }
+ }
+ const strokeRadius = (obj.strokeWidth ?? 50) / 2;
+ objMinX -= strokeRadius;
+ objMinY -= strokeRadius;
+ objMaxX += strokeRadius;
+ objMaxY += strokeRadius;
+ } else if (obj.type === 'image') {
+ objMinX = mask.position.x;
+ objMinY = mask.position.y;
+ objMaxX = objMinX + obj.image.width;
+ objMaxY = objMinY + obj.image.height;
+ }
+ minX = Math.min(minX, objMinX);
+ minY = Math.min(minY, objMinY);
+ maxX = Math.max(maxX, objMaxX);
+ maxY = Math.max(maxY, objMaxY);
+ }
+ }
+ if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
+ return null;
+ }
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
+ }, [inpaintMasks]);
+
+ const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]);
+ const handleAdjustBbox = useCallback(() => {
+ if (!maskBbox) {
+ return;
+ }
+ const padding = maskBlur + 8;
+ const adjustedBbox: Rect = {
+ x: maskBbox.x - padding,
+ y: maskBbox.y - padding,
+ width: maskBbox.width + padding * 2,
+ height: maskBbox.height + padding * 2,
+ };
+ dispatch(bboxChangedFromCanvas(adjustedBbox));
+ }, [dispatch, maskBbox, maskBlur]);
+
+ const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0);
+ if (!hasValidMasks) {
+ return null;
+ }
+
+ return (
+ }>
+ {t('controlLayers.adjustBboxToMasks')}
+
+ );
+});
+
+InpaintMaskMenuItemsAdjustBbox.displayName = 'InpaintMaskMenuItemsAdjustBbox';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx
new file mode 100644
index 00000000000..933f26fa1d5
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx
@@ -0,0 +1,36 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice';
+import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiSelectionInverseBold } from 'react-icons/pi';
+
+export const InpaintMaskMenuItemsInvert = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
+ const canvasSlice = useAppSelector(selectCanvasSlice);
+
+ const handleInvertMask = useCallback(() => {
+ dispatch(inpaintMaskInverted({ entityIdentifier }));
+ }, [dispatch, entityIdentifier]);
+
+ // Only show if there are objects to invert and we have a valid bounding box
+ const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === entityIdentifier.id);
+ const hasObjects = Boolean(entity?.objects && entity.objects.length > 0);
+ const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0;
+
+ if (!hasObjects || !hasBbox) {
+ return null;
+ }
+
+ return (
+ }>
+ {t('controlLayers.invertMask')}
+
+ );
+});
+
+InpaintMaskMenuItemsInvert.displayName = 'InpaintMaskMenuItemsInvert';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx
index 453d13b3c50..062428343b4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx
@@ -9,9 +9,11 @@ import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/
import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton';
import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale';
import { CanvasToolbarUndoButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarUndoButton';
+import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey';
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey';
import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey';
+import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey';
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey';
import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey';
@@ -28,6 +30,8 @@ export const CanvasToolbar = memo(() => {
useCanvasTransformHotkey();
useCanvasFilterHotkey();
useCanvasToggleNonRasterLayersHotkey();
+ useCanvasInvertMaskHotkey();
+ useCanvasAdjustBboxHotkey();
return (
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts
new file mode 100644
index 00000000000..4878f7850c1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts
@@ -0,0 +1,167 @@
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
+import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
+import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
+import type {
+ CanvasBrushLineState,
+ CanvasBrushLineWithPressureState,
+ CanvasEraserLineState,
+ CanvasEraserLineWithPressureState,
+ CanvasImageState,
+ CanvasRectState,
+ Rect,
+} from 'features/controlLayers/store/types';
+import { maskObjectsToBitmap } from 'features/controlLayers/util/bitmapToMaskObjects';
+import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
+import { useCallback, useMemo } from 'react';
+
+export const useCanvasAdjustBboxHotkey = () => {
+ useAssertSingleton('useCanvasAdjustBboxHotkey');
+ const dispatch = useAppDispatch();
+ const canvasSlice = useAppSelector(selectCanvasSlice);
+ const maskBlur = useAppSelector(selectMaskBlur);
+ const isBusy = useCanvasIsBusy();
+ const inpaintMasks = canvasSlice.inpaintMasks.entities;
+ const bboxRect = canvasSlice.bbox.rect;
+
+ // Calculate the bounding box that contains all inpaint masks
+ const calculateMaskBbox = useCallback((): Rect | null => {
+ if (inpaintMasks.length === 0) {
+ return null;
+ }
+
+ // Use the current bbox as the reference container
+ const canvasWidth = bboxRect.width;
+ const canvasHeight = bboxRect.height;
+
+ // Collect all mask objects and adjust their positions relative to the bbox
+ const allObjects: (
+ | CanvasBrushLineState
+ | CanvasBrushLineWithPressureState
+ | CanvasEraserLineState
+ | CanvasEraserLineWithPressureState
+ | CanvasRectState
+ | CanvasImageState
+ )[] = [];
+ for (const mask of inpaintMasks) {
+ if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) {
+ continue;
+ }
+
+ // Adjust object positions relative to the bbox (not the entity position)
+ for (const obj of mask.objects) {
+ if (obj.type === 'rect') {
+ const adjustedObj = {
+ ...obj,
+ rect: {
+ ...obj.rect,
+ x: obj.rect.x + mask.position.x - bboxRect.x,
+ y: obj.rect.y + mask.position.y - bboxRect.y,
+ },
+ };
+ allObjects.push(adjustedObj);
+ } else if (
+ obj.type === 'brush_line' ||
+ obj.type === 'brush_line_with_pressure' ||
+ obj.type === 'eraser_line' ||
+ obj.type === 'eraser_line_with_pressure'
+ ) {
+ const adjustedPoints: number[] = [];
+ for (let i = 0; i < obj.points.length; i += 2) {
+ adjustedPoints.push((obj.points[i] ?? 0) + mask.position.x - bboxRect.x);
+ adjustedPoints.push((obj.points[i + 1] ?? 0) + mask.position.y - bboxRect.y);
+ }
+ const adjustedObj = {
+ ...obj,
+ points: adjustedPoints,
+ };
+ allObjects.push(adjustedObj);
+ } else if (obj.type === 'image') {
+ // For image objects, we need to handle them differently since they don't have rect or points
+ // We'll skip them for now as they're not commonly used in masks
+ continue;
+ }
+ }
+ }
+
+ if (allObjects.length === 0) {
+ return null;
+ }
+
+ // Render the consolidated mask to a bitmap
+ const bitmap = maskObjectsToBitmap(allObjects, canvasWidth, canvasHeight);
+ const { width, height, data } = bitmap;
+
+ // Find the actual bounds of the rendered mask
+ let maskMinX = width;
+ let maskMinY = height;
+ let maskMaxX = 0;
+ let maskMaxY = 0;
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const pixelIndex = (y * width + x) * 4;
+ const alpha = data[pixelIndex + 3] ?? 0;
+
+ // If this pixel has any opacity, it's part of the mask
+ if (alpha > 0) {
+ maskMinX = Math.min(maskMinX, x);
+ maskMinY = Math.min(maskMinY, y);
+ maskMaxX = Math.max(maskMaxX, x);
+ maskMaxY = Math.max(maskMaxY, y);
+ }
+ }
+ }
+
+ // If no mask pixels found, return null
+ if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) {
+ return null;
+ }
+
+ // Clamp the mask bounds to the bbox boundaries
+ maskMinX = Math.max(0, maskMinX);
+ maskMinY = Math.max(0, maskMinY);
+ maskMaxX = Math.min(width - 1, maskMaxX);
+ maskMaxY = Math.min(height - 1, maskMaxY);
+
+ // Convert back to world coordinates relative to the bbox
+ return {
+ x: bboxRect.x + maskMinX,
+ y: bboxRect.y + maskMinY,
+ width: maskMaxX - maskMinX + 1,
+ height: maskMaxY - maskMinY + 1,
+ };
+ }, [inpaintMasks, bboxRect]);
+
+ const handleAdjustBbox = useCallback(() => {
+ const maskBbox = calculateMaskBbox();
+ if (!maskBbox) {
+ return;
+ }
+
+ const padding = maskBlur + 8;
+ const adjustedBbox: Rect = {
+ x: maskBbox.x - padding,
+ y: maskBbox.y - padding,
+ width: maskBbox.width + padding * 2,
+ height: maskBbox.height + padding * 2,
+ };
+
+ dispatch(bboxChangedFromCanvas(adjustedBbox));
+ }, [dispatch, calculateMaskBbox, maskBlur]);
+
+ const isAdjustBboxAllowed = useMemo(() => {
+ const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects && mask.objects.length > 0);
+ return hasValidMasks;
+ }, [inpaintMasks]);
+
+ useRegisteredHotkeys({
+ id: 'adjustBbox',
+ category: 'canvas',
+ callback: handleAdjustBbox,
+ options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true },
+ dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox],
+ });
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts
new file mode 100644
index 00000000000..01698062e29
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts
@@ -0,0 +1,55 @@
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice';
+import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
+import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
+import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
+import { useCallback, useMemo } from 'react';
+
+export const useCanvasInvertMaskHotkey = () => {
+ useAssertSingleton('useCanvasInvertMaskHotkey');
+ const dispatch = useAppDispatch();
+ const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
+ const canvasSlice = useAppSelector(selectCanvasSlice);
+ const isBusy = useCanvasIsBusy();
+
+ const handleInvertMask = useCallback(() => {
+ if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') {
+ return;
+ }
+
+ // Check if the selected entity has objects and there's a valid bounding box
+ const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id);
+ const hasObjects = entity?.objects && entity.objects.length > 0;
+ const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0;
+
+ if (!hasObjects || !hasBbox) {
+ return;
+ }
+
+ dispatch(
+ inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'inpaint_mask'> })
+ );
+ }, [dispatch, selectedEntityIdentifier, canvasSlice]);
+
+ const isInvertMaskAllowed = useMemo(() => {
+ if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') {
+ return false;
+ }
+
+ const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id);
+ const hasObjects = entity?.objects && entity.objects.length > 0;
+ const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0;
+
+ return hasObjects && hasBbox;
+ }, [selectedEntityIdentifier, canvasSlice]);
+
+ useRegisteredHotkeys({
+ id: 'invertMask',
+ category: 'canvas',
+ callback: handleInvertMask,
+ options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true },
+ dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask],
+ });
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
index e6af138b1d8..cc2e4dc5c2b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
@@ -15,10 +15,15 @@ import {
selectRegionalGuidanceReferenceImage,
} from 'features/controlLayers/store/selectors';
import type {
+ CanvasBrushLineState,
+ CanvasBrushLineWithPressureState,
CanvasEntityStateFromType,
CanvasEntityType,
+ CanvasEraserLineState,
+ CanvasEraserLineWithPressureState,
CanvasInpaintMaskState,
CanvasMetadata,
+ CanvasRectState,
ControlLoRAConfig,
EntityMovedByPayload,
FillStyle,
@@ -996,6 +1001,154 @@ export const canvasSlice = createSlice({
entity.denoiseLimit = undefined;
}
},
+ inpaintMaskInverted: (state, action: PayloadAction>) => {
+ const { entityIdentifier } = action.payload;
+ const entity = selectEntity(state, entityIdentifier);
+ if (!entity || entity.type !== 'inpaint_mask') {
+ return;
+ }
+
+ // If there are no objects to invert, do nothing
+ if (entity.objects.length === 0) {
+ return;
+ }
+
+ // Get the current bbox dimensions for the mask
+ const bboxRect = state.bbox.rect;
+
+ // Create a clip rect that constrains all objects to the bbox
+ const bboxClip = {
+ x: bboxRect.x - entity.position.x,
+ y: bboxRect.y - entity.position.y,
+ width: bboxRect.width,
+ height: bboxRect.height,
+ };
+
+ // Check if we already have a full rectangle covering the entire bbox
+ const hasFullRect = entity.objects.some((obj) => {
+ if (obj.type !== 'rect') {
+ return false;
+ }
+ const { x, y, width, height } = obj.rect;
+ return (
+ Math.abs(x - bboxClip.x) < 1 &&
+ Math.abs(y - bboxClip.y) < 1 &&
+ Math.abs(width - bboxClip.width) < 1 &&
+ Math.abs(height - bboxClip.height) < 1
+ );
+ });
+
+ // Convert existing brush lines to eraser lines to "punch holes" in the full rectangle
+ const invertedObjects: (
+ | CanvasRectState
+ | CanvasEraserLineState
+ | CanvasEraserLineWithPressureState
+ | CanvasBrushLineState
+ | CanvasBrushLineWithPressureState
+ )[] = [];
+
+ // Only add the fill rectangle if we don't already have one
+ if (!hasFullRect) {
+ const fillRect: CanvasRectState = {
+ id: getPrefixedId('rect'),
+ type: 'rect',
+ rect: {
+ x: bboxRect.x - entity.position.x,
+ y: bboxRect.y - entity.position.y,
+ width: bboxRect.width,
+ height: bboxRect.height,
+ },
+ color: { r: 255, g: 255, b: 255, a: 1 },
+ };
+
+ invertedObjects.push(fillRect);
+ }
+
+ for (const obj of entity.objects) {
+ if (obj.type === 'brush_line') {
+ // Convert brush line to eraser line, constrained to bbox
+ const eraserLine: CanvasEraserLineState = {
+ id: getPrefixedId('eraser_line'),
+ type: 'eraser_line',
+ strokeWidth: obj.strokeWidth,
+ points: obj.points,
+ clip: bboxClip, // Ensure the eraser line is clipped to the bbox
+ };
+ invertedObjects.push(eraserLine);
+ } else if (obj.type === 'brush_line_with_pressure') {
+ // Convert brush line with pressure to eraser line with pressure, constrained to bbox
+ const eraserLine: CanvasEraserLineWithPressureState = {
+ id: getPrefixedId('eraser_line'),
+ type: 'eraser_line_with_pressure',
+ strokeWidth: obj.strokeWidth,
+ points: obj.points,
+ clip: bboxClip, // Ensure the eraser line is clipped to the bbox
+ };
+ invertedObjects.push(eraserLine);
+ } else if (obj.type === 'rect') {
+ // Convert rectangle to eraser rectangle, constrained to bbox
+ const { x, y, width, height } = obj.rect;
+
+ // Clamp the rectangle to the bbox boundaries
+ const clampedX = Math.max(x, bboxClip.x);
+ const clampedY = Math.max(y, bboxClip.y);
+ const clampedWidth = Math.min(width, bboxClip.x + bboxClip.width - clampedX);
+ const clampedHeight = Math.min(height, bboxClip.y + bboxClip.height - clampedY);
+
+ // Only add the eraser rectangle if it has valid dimensions
+ if (clampedWidth > 0 && clampedHeight > 0) {
+ const points = [
+ clampedX,
+ clampedY,
+ clampedX + clampedWidth,
+ clampedY,
+ clampedX + clampedWidth,
+ clampedY + clampedHeight,
+ clampedX,
+ clampedY + clampedHeight,
+ clampedX,
+ clampedY, // Close the rectangle
+ ];
+
+ const eraserLine: CanvasEraserLineState = {
+ id: getPrefixedId('eraser_line'),
+ type: 'eraser_line',
+ points,
+ strokeWidth: Math.max(clampedWidth, clampedHeight) / 2, // Use a stroke width that covers the rectangle
+ clip: bboxClip, // Ensure the eraser line is clipped to the bbox
+ };
+ invertedObjects.push(eraserLine);
+ }
+ } else if (obj.type === 'eraser_line') {
+ // Convert eraser line to brush line, constrained to bbox
+ const brushLine: CanvasBrushLineState = {
+ id: getPrefixedId('brush_line'),
+ type: 'brush_line',
+ strokeWidth: obj.strokeWidth,
+ points: obj.points,
+ clip: bboxClip, // Ensure the brush line is clipped to the bbox
+ color: { r: 255, g: 255, b: 255, a: 1 },
+ };
+ invertedObjects.push(brushLine);
+ } else if (obj.type === 'eraser_line_with_pressure') {
+ // Convert eraser line with pressure to brush line with pressure, constrained to bbox
+ const brushLine: CanvasBrushLineWithPressureState = {
+ id: getPrefixedId('brush_line'),
+ type: 'brush_line_with_pressure',
+ strokeWidth: obj.strokeWidth,
+ points: obj.points,
+ clip: bboxClip, // Ensure the brush line is clipped to the bbox
+ color: { r: 255, g: 255, b: 255, a: 1 },
+ };
+ invertedObjects.push(brushLine);
+ }
+ // Note: Image objects are not handled in this simple approach
+ // They would need to be processed through the compositor
+ }
+
+ // Replace the entity's objects with the inverted mask objects
+ entity.objects = invertedObjects;
+ },
//#region BBox
bboxScaledWidthChanged: (state, action: PayloadAction) => {
const gridSize = getGridSize(state.bbox.modelBase);
@@ -1675,6 +1828,7 @@ export const {
inpaintMaskDenoiseLimitAdded,
inpaintMaskDenoiseLimitChanged,
inpaintMaskDenoiseLimitDeleted,
+ inpaintMaskInverted,
// inpaintMaskRecalled,
} = canvasSlice.actions;
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts
new file mode 100644
index 00000000000..a58bb20ef6a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts
@@ -0,0 +1,243 @@
+import { getPrefixedId } from 'features/controlLayers/konva/util';
+import type {
+ CanvasBrushLineState,
+ CanvasBrushLineWithPressureState,
+ CanvasEraserLineState,
+ CanvasEraserLineWithPressureState,
+ CanvasImageState,
+ CanvasRectState,
+ RgbaColor,
+} from 'features/controlLayers/store/types';
+
+/**
+ * Options for converting bitmap to mask objects
+ */
+export interface BitmapToMaskOptions {
+ /**
+ * The threshold for considering a pixel as masked (0-255)
+ * Pixels with alpha >= threshold are considered masked
+ */
+ threshold?: number;
+ /**
+ * The color to use for brush lines
+ */
+ brushColor?: RgbaColor;
+ /**
+ * The stroke width for brush lines
+ */
+ strokeWidth?: number;
+ /**
+ * Whether to use pressure-sensitive lines
+ */
+ usePressure?: boolean;
+ /**
+ * The pressure value to use for pressure-sensitive lines (0-1)
+ */
+ pressure?: number;
+}
+
+/**
+ * Default options for bitmap to mask conversion
+ */
+const DEFAULT_OPTIONS: Required = {
+ threshold: 128,
+ brushColor: { r: 255, g: 255, b: 255, a: 1 },
+ strokeWidth: 50,
+ usePressure: false,
+ pressure: 1.0,
+};
+
+/**
+ * Converts a bitmap (ImageData) to mask objects (brush lines, eraser lines, rectangles)
+ *
+ * @param imageData - The bitmap data to convert
+ * @param options - Conversion options
+ * @returns Array of mask objects
+ */
+export function bitmapToMaskObjects(
+ imageData: ImageData,
+ options: BitmapToMaskOptions = {}
+): (
+ | CanvasBrushLineState
+ | CanvasBrushLineWithPressureState
+ | CanvasEraserLineState
+ | CanvasEraserLineWithPressureState
+ | CanvasRectState
+ | CanvasImageState
+)[] {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+ const { width, height, data } = imageData;
+ const objects: (
+ | CanvasBrushLineState
+ | CanvasBrushLineWithPressureState
+ | CanvasEraserLineState
+ | CanvasEraserLineWithPressureState
+ | CanvasRectState
+ | CanvasImageState
+ )[] = [];
+
+ // For now, we'll create a simple approach that creates rectangles for masked areas
+ // This can be enhanced later to create more sophisticated brush/eraser line patterns
+
+ // Scan the image data to find masked areas
+ for (let y = 0; y < height; y += opts.strokeWidth) {
+ for (let x = 0; x < width; x += opts.strokeWidth) {
+ // Check if this pixel is masked
+ const pixelIndex = (y * width + x) * 4;
+ const alpha = data[pixelIndex + 3] ?? 0;
+
+ if (alpha >= opts.threshold) {
+ // Create a rectangle for this masked area
+ const rect: CanvasRectState = {
+ id: getPrefixedId('rect'),
+ type: 'rect',
+ rect: {
+ x,
+ y,
+ width: Math.min(opts.strokeWidth, width - x),
+ height: Math.min(opts.strokeWidth, height - y),
+ },
+ color: opts.brushColor,
+ };
+ objects.push(rect);
+ }
+ }
+ }
+
+ return objects;
+}
+
+/**
+ * Inverts a bitmap by flipping the alpha channel
+ *
+ * @param imageData - The bitmap data to invert
+ * @returns New ImageData with inverted alpha channel
+ */
+export function invertBitmap(imageData: ImageData): ImageData {
+ const { width, height, data } = imageData;
+ const newImageData = new ImageData(width, height);
+ const newData = newImageData.data;
+
+ for (let i = 0; i < data.length; i += 4) {
+ // Copy RGB values
+ newData[i] = data[i] ?? 0; // R
+ newData[i + 1] = data[i + 1] ?? 0; // G
+ newData[i + 2] = data[i + 2] ?? 0; // B
+ // Invert alpha
+ newData[i + 3] = 255 - (data[i + 3] ?? 0); // A
+ }
+
+ return newImageData;
+}
+
+/**
+ * Converts mask objects to a bitmap (ImageData)
+ * This is a simplified version that creates a basic bitmap representation
+ *
+ * @param objects - Array of mask objects
+ * @param width - Width of the output bitmap
+ * @param height - Height of the output bitmap
+ * @returns ImageData representing the mask
+ */
+export function maskObjectsToBitmap(
+ objects: (
+ | CanvasBrushLineState
+ | CanvasBrushLineWithPressureState
+ | CanvasEraserLineState
+ | CanvasEraserLineWithPressureState
+ | CanvasRectState
+ | CanvasImageState
+ )[],
+ width: number,
+ height: number
+): ImageData {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+
+ if (!ctx) {
+ throw new Error('Failed to get canvas context');
+ }
+
+ // Clear canvas with transparent background
+ ctx.clearRect(0, 0, width, height);
+
+ // Draw each object
+ for (const obj of objects) {
+ if (obj.type === 'rect') {
+ ctx.fillStyle = `rgba(${obj.color.r}, ${obj.color.g}, ${obj.color.b}, ${obj.color.a})`;
+ ctx.fillRect(obj.rect.x, obj.rect.y, obj.rect.width, obj.rect.height);
+ } else if (obj.type === 'brush_line' || obj.type === 'brush_line_with_pressure') {
+ ctx.strokeStyle = `rgba(${obj.color.r}, ${obj.color.g}, ${obj.color.b}, ${obj.color.a})`;
+ ctx.lineWidth = obj.strokeWidth;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ // Draw the line
+ ctx.beginPath();
+ for (let i = 0; i < obj.points.length; i += 2) {
+ const x = obj.points[i] ?? 0;
+ const y = obj.points[i + 1] ?? 0;
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.stroke();
+ } else if (obj.type === 'eraser_line' || obj.type === 'eraser_line_with_pressure') {
+ // Eraser lines use destination-out composite operation
+ ctx.globalCompositeOperation = 'destination-out';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
+ ctx.lineWidth = obj.strokeWidth;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ // Draw the line
+ ctx.beginPath();
+ for (let i = 0; i < obj.points.length; i += 2) {
+ const x = obj.points[i] ?? 0;
+ const y = obj.points[i + 1] ?? 0;
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.stroke();
+
+ // Reset composite operation
+ ctx.globalCompositeOperation = 'source-over';
+ } else if (obj.type === 'image') {
+ // For image objects, we need to load the image and draw it
+ // This is a simplified approach - in a real implementation, you'd want to handle image loading properly
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+
+ // Create a temporary canvas to draw the image
+ const tempCanvas = document.createElement('canvas');
+ const tempCtx = tempCanvas.getContext('2d');
+ if (tempCtx) {
+ tempCanvas.width = obj.image.width;
+ tempCanvas.height = obj.image.height;
+
+ // Draw the image to the temp canvas
+ if ('image_name' in obj.image) {
+ // This would need proper image loading from the server
+ // For now, we'll skip image objects in the mask conversion
+ // Image objects are not supported in this simple mask conversion
+ } else {
+ // Data URL image
+ img.src = obj.image.dataURL;
+ tempCtx.drawImage(img, 0, 0);
+
+ // Draw the temp canvas to the main canvas
+ ctx.drawImage(tempCanvas, 0, 0);
+ }
+ }
+ }
+ }
+
+ return ctx.getImageData(0, 0, width, height);
+}
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
index aaf3c6a26fc..437b939402d 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
@@ -123,6 +123,8 @@ export const useHotkeyData = (): HotkeysData => {
addHotkey('canvas', 'applySegmentAnything', ['enter']);
addHotkey('canvas', 'cancelSegmentAnything', ['esc']);
addHotkey('canvas', 'toggleNonRasterLayers', ['shift+h']);
+ addHotkey('canvas', 'invertMask', ['shift+v']);
+ addHotkey('canvas', 'adjustBbox', ['shift+b']);
// Workflows
addHotkey('workflows', 'addNode', ['shift+a', 'space']);