From f4bbd63e505496b64a93bbac4199acedbdd51f99 Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Mon, 17 Nov 2025 23:07:21 +0200
Subject: [PATCH 1/8] chore: localize extraction errors
---
invokeai/frontend/web/public/locales/en.json | 4 +
.../InpaintMask/InpaintMaskMenuItems.tsx | 2 +
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 153 ++++++++++++++++++
3 files changed, 159 insertions(+)
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 8a6bd7b337e..ba992f79382 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -2105,7 +2105,11 @@
"newSession": "New Session",
"clearCaches": "Clear Caches",
"recalculateRects": "Recalculate Rects",
+ "canvasIsEmpty": "Canvas is empty",
+ "extractMaskedAreaError": "Unable to extract masked area",
+ "extractMaskedAreaDataMissing": "Cannot extract: image or mask data is missing.",
"clipToBbox": "Clip Strokes to Bbox",
+ "extractRegion": "Extract Region",
"outputOnlyMaskedRegions": "Output Only Generated Regions",
"addLayer": "Add Layer",
"duplicate": "Duplicate",
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..ea1c2bdbf67 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
@@ -10,6 +10,7 @@ import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/component
import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers';
import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu';
import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu';
+import { InpaintMaskMenuItemsExtractMaskedArea } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea';
import { memo } from 'react';
export const InpaintMaskMenuItems = memo(() => {
@@ -24,6 +25,7 @@ export const InpaintMaskMenuItems = memo(() => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
new file mode 100644
index 00000000000..40708fa9663
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -0,0 +1,153 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { logger } from 'app/logging/logger';
+import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { canvasToImageData, getPrefixedId } from 'features/controlLayers/konva/util';
+import type { CanvasImageState, Rect } from 'features/controlLayers/store/types';
+import { memo, useCallback } from 'react';
+import { PiSelectionBackgroundBold } from 'react-icons/pi';
+import { serializeError } from 'serialize-error';
+import { useTranslation } from 'react-i18next';
+
+import { toast } from 'features/toast/toast';
+
+const log = logger('canvas');
+
+export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
+ const canvasManager = useCanvasManager();
+ const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
+ const isBusy = useCanvasIsBusy();
+ const { t } = useTranslation();
+
+ const onExtract = useCallback(() => {
+ // The active inpaint mask layer is required to build the mask used for extraction.
+ const maskAdapter = canvasManager.getAdapter(entityIdentifier);
+ if (!maskAdapter) {
+ log.error({ entityIdentifier }, 'Inpaint mask adapter not found when extracting masked area');
+ toast({ status: 'error', title: t('controlLayers.extractMaskedAreaError') });
+ return;
+ }
+
+ try {
+ // Use the full stage dimensions so the mask extraction covers the entire canvas.
+ const { width, height } = canvasManager.stage.getSize();
+ const rect: Rect = {
+ x: 0,
+ y: 0,
+ width: Math.floor(width),
+ height: Math.floor(height),
+ };
+
+ // Abort when the canvas is effectively empty—no pixels to extract.
+ if (rect.width <= 0 || rect.height <= 0) {
+ toast({ status: 'warning', title: t('controlLayers.canvasIsEmpty') });
+ return;
+ }
+
+ // Gather the visible raster layer adapters so we can composite them into a single bitmap.
+ const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
+
+ let compositeImageData: ImageData;
+ if (rasterAdapters.length === 0) {
+ // No visible raster layers—create a transparent buffer that matches the canvas bounds.
+ compositeImageData = new ImageData(rect.width, rect.height);
+ } else {
+ // Render the visible raster layers into an offscreen canvas restricted to the canvas bounds.
+ const compositeCanvas = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect);
+ compositeImageData = canvasToImageData(compositeCanvas);
+ }
+
+ // Render the inpaint mask layer into a canvas so we have the alpha data that defines the mask.
+ const maskCanvas = maskAdapter.getCanvas(rect);
+ const maskImageData = canvasToImageData(maskCanvas);
+
+ if (
+ maskImageData.width !== compositeImageData.width ||
+ maskImageData.height !== compositeImageData.height
+ ) {
+ // Bail out if the mask and composite buffers disagree on dimensions.
+ log.error(
+ {
+ maskDimensions: { width: maskImageData.width, height: maskImageData.height },
+ compositeDimensions: { width: compositeImageData.width, height: compositeImageData.height },
+ },
+ 'Mask and composite dimensions did not match when extracting masked area'
+ );
+ toast({ status: 'error', title: t('controlLayers.extractMaskedAreaError') });
+ return;
+ }
+
+ const compositeArray = compositeImageData.data;
+ const maskArray = maskImageData.data;
+
+ if (!compositeArray || !maskArray) {
+ toast({ status: 'error', title: t('controlLayers.extractMaskedAreaDataMissing') });
+ return;
+ }
+
+ const outputArray = new Uint8ClampedArray(compositeArray.length);
+
+ // Apply the mask alpha channel to each pixel in the composite, keeping RGB untouched and only masking alpha.
+ for (let i = 0; i < compositeArray.length; i += 4) {
+ const maskAlpha = ((maskArray[i + 3] ?? 0) / 255) || 0;
+ outputArray[i] = compositeArray[i] ?? 0;
+ outputArray[i + 1] = compositeArray[i + 1] ?? 0;
+ outputArray[i + 2] = compositeArray[i + 2] ?? 0;
+ outputArray[i + 3] = Math.round((compositeArray[i + 3] ?? 0) * maskAlpha);
+ }
+
+ // Package the masked pixels into an ImageData and draw them to an offscreen canvas.
+ const outputImageData = new ImageData(outputArray, rect.width, rect.height);
+ const outputCanvas = document.createElement('canvas');
+ outputCanvas.width = rect.width;
+ outputCanvas.height = rect.height;
+ const outputContext = outputCanvas.getContext('2d');
+
+ if (!outputContext) {
+ throw new Error('Failed to create canvas context for masked extraction');
+ }
+
+ outputContext.putImageData(outputImageData, 0, 0);
+
+ // Convert the offscreen canvas into an Invoke canvas image state for insertion into the layer stack.
+ const imageState: CanvasImageState = {
+ id: getPrefixedId('image'),
+ type: 'image',
+ image: {
+ dataURL: outputCanvas.toDataURL('image/png'),
+ width: rect.width,
+ height: rect.height,
+ },
+ };
+
+ // Insert the new raster layer just after the last existing raster layer so it appears above the mask.
+ const addAfter = canvasManager.stateApi.getRasterLayersState().entities.at(-1)?.id;
+
+ canvasManager.stateApi.addRasterLayer({
+ overrides: {
+ objects: [imageState],
+ position: { x: rect.x, y: rect.y },
+ },
+ isSelected: true,
+ addAfter,
+ });
+ } catch (error) {
+ log.error({ error: serializeError(error as Error) }, 'Failed to extract masked area to raster layer');
+ toast({ status: 'error', title: t('controlLayers.extractMaskedAreaError') });
+ }
+ }, [canvasManager, entityIdentifier, t]);
+
+ return (
+ }
+ isDisabled={isBusy}
+ >
+ {t('controlLayers.extractRegion')}
+
+ );
+});
+
+InpaintMaskMenuItemsExtractMaskedArea.displayName = 'InpaintMaskMenuItemsExtractMaskedArea';
+
From ca69db9abc61db9b1167e5f7e4ca30a8efffc2fa Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Mon, 17 Nov 2025 23:17:03 +0200
Subject: [PATCH 2/8] chore: rename extract masked area menu item
---
...skMenuItemsExtractMaskedArea.tsx => ExtractMaskedArea.tsx} | 4 ++--
.../components/InpaintMask/InpaintMaskMenuItems.tsx | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
rename invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/{InpaintMaskMenuItemsExtractMaskedArea.tsx => ExtractMaskedArea.tsx} (97%)
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/ExtractMaskedArea.tsx
similarity index 97%
rename from invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
rename to invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/ExtractMaskedArea.tsx
index 40708fa9663..b928f278ade 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/ExtractMaskedArea.tsx
@@ -14,7 +14,7 @@ import { toast } from 'features/toast/toast';
const log = logger('canvas');
-export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
+export const ExtractMaskedArea = memo(() => {
const canvasManager = useCanvasManager();
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
const isBusy = useCanvasIsBusy();
@@ -149,5 +149,5 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
);
});
-InpaintMaskMenuItemsExtractMaskedArea.displayName = 'InpaintMaskMenuItemsExtractMaskedArea';
+ExtractMaskedArea.displayName = 'ExtractMaskedArea';
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 ea1c2bdbf67..6248f1cafbc 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
@@ -10,7 +10,7 @@ import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/component
import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers';
import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu';
import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu';
-import { InpaintMaskMenuItemsExtractMaskedArea } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea';
+import { ExtractMaskedArea } from 'features/controlLayers/components/InpaintMask/ExtractMaskedArea';
import { memo } from 'react';
export const InpaintMaskMenuItems = memo(() => {
@@ -25,7 +25,7 @@ export const InpaintMaskMenuItems = memo(() => {
-
+
From 143c18fb6559d2f4acddee238cfd5d70119636b2 Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Tue, 18 Nov 2025 00:02:45 +0200
Subject: [PATCH 3/8] chore: rename inpaint mask extract component
---
.../components/InpaintMask/InpaintMaskMenuItems.tsx | 4 ++--
...a.tsx => InpaintMaskMenuItemsExtractMaskedArea.tsx} | 10 +++-------
2 files changed, 5 insertions(+), 9 deletions(-)
rename invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/{ExtractMaskedArea.tsx => InpaintMaskMenuItemsExtractMaskedArea.tsx} (96%)
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 6248f1cafbc..ea1c2bdbf67 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx
@@ -10,7 +10,7 @@ import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/component
import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers';
import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu';
import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu';
-import { ExtractMaskedArea } from 'features/controlLayers/components/InpaintMask/ExtractMaskedArea';
+import { InpaintMaskMenuItemsExtractMaskedArea } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea';
import { memo } from 'react';
export const InpaintMaskMenuItems = memo(() => {
@@ -25,7 +25,7 @@ export const InpaintMaskMenuItems = memo(() => {
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/ExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
similarity index 96%
rename from invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/ExtractMaskedArea.tsx
rename to invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
index b928f278ade..e358da5a3f3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/ExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -14,7 +14,7 @@ import { toast } from 'features/toast/toast';
const log = logger('canvas');
-export const ExtractMaskedArea = memo(() => {
+export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
const canvasManager = useCanvasManager();
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
const isBusy = useCanvasIsBusy();
@@ -139,15 +139,11 @@ export const ExtractMaskedArea = memo(() => {
}, [canvasManager, entityIdentifier, t]);
return (
- }
- isDisabled={isBusy}
- >
+ } isDisabled={isBusy}>
{t('controlLayers.extractRegion')}
);
});
-ExtractMaskedArea.displayName = 'ExtractMaskedArea';
+InpaintMaskMenuItemsExtractMaskedArea.displayName = 'InpaintMaskMenuItemsExtractMaskedArea';
From da2cbaf232c9ab33e42a71abb22d07747fb969bf Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Tue, 18 Nov 2025 01:14:48 +0200
Subject: [PATCH 4/8] fix: use mask bounds for extraction region
---
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
index e358da5a3f3..ac128a2b51d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -30,13 +30,14 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
}
try {
- // Use the full stage dimensions so the mask extraction covers the entire canvas.
- const { width, height } = canvasManager.stage.getSize();
+ // Use the mask's bounding box in stage coordinates to constrain the extraction region.
+ const maskPixelRect = maskAdapter.transformer.$pixelRect.get();
+ const maskPosition = maskAdapter.state.position;
const rect: Rect = {
- x: 0,
- y: 0,
- width: Math.floor(width),
- height: Math.floor(height),
+ x: Math.floor(maskPosition.x + maskPixelRect.x),
+ y: Math.floor(maskPosition.y + maskPixelRect.y),
+ width: Math.floor(maskPixelRect.width),
+ height: Math.floor(maskPixelRect.height),
};
// Abort when the canvas is effectively empty—no pixels to extract.
From 01edf81391b02c7f00411adbbc1ace6239e3e36c Mon Sep 17 00:00:00 2001
From: DustyShoe
Date: Sun, 23 Nov 2025 10:20:05 +0200
Subject: [PATCH 5/8] Prettier format applied to
InpaintMaskMenuItemsExtractMaskedArea.tsx
---
.../InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
index ac128a2b51d..2c1a630ac9b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -5,12 +5,11 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { canvasToImageData, getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasImageState, Rect } from 'features/controlLayers/store/types';
+import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
import { PiSelectionBackgroundBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
-import { useTranslation } from 'react-i18next';
-
-import { toast } from 'features/toast/toast';
const log = logger('canvas');
From 9351f5c0e52f088eea681950d3bb63ef67897d4f Mon Sep 17 00:00:00 2001
From: DustyShoe
Date: Sun, 23 Nov 2025 12:46:18 +0200
Subject: [PATCH 6/8] Fix base64 image import bug in extracted area in
InpaintMaskMenuItemsExtractMaskedArea.tsx and removed unused locales entries
in en.json
---
invokeai/frontend/web/public/locales/en.json | 3 -
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 233 +++++++++---------
2 files changed, 120 insertions(+), 116 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index ba992f79382..eca1cf52356 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -2105,9 +2105,6 @@
"newSession": "New Session",
"clearCaches": "Clear Caches",
"recalculateRects": "Recalculate Rects",
- "canvasIsEmpty": "Canvas is empty",
- "extractMaskedAreaError": "Unable to extract masked area",
- "extractMaskedAreaDataMissing": "Cannot extract: image or mask data is missing.",
"clipToBbox": "Clip Strokes to Bbox",
"extractRegion": "Extract Region",
"outputOnlyMaskedRegions": "Output Only Generated Regions",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
index 2c1a630ac9b..9b243200f37 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -3,13 +3,15 @@ import { logger } from 'app/logging/logger';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
-import { canvasToImageData, getPrefixedId } from 'features/controlLayers/konva/util';
+import { canvasToBlob, canvasToImageData } from 'features/controlLayers/konva/util';
import type { CanvasImageState, Rect } from 'features/controlLayers/store/types';
+import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiSelectionBackgroundBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
+import { uploadImage } from 'services/api/endpoints/images';
const log = logger('canvas');
@@ -20,123 +22,128 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
const { t } = useTranslation();
const onExtract = useCallback(() => {
- // The active inpaint mask layer is required to build the mask used for extraction.
- const maskAdapter = canvasManager.getAdapter(entityIdentifier);
- if (!maskAdapter) {
- log.error({ entityIdentifier }, 'Inpaint mask adapter not found when extracting masked area');
- toast({ status: 'error', title: t('controlLayers.extractMaskedAreaError') });
- return;
- }
-
- try {
- // Use the mask's bounding box in stage coordinates to constrain the extraction region.
- const maskPixelRect = maskAdapter.transformer.$pixelRect.get();
- const maskPosition = maskAdapter.state.position;
- const rect: Rect = {
- x: Math.floor(maskPosition.x + maskPixelRect.x),
- y: Math.floor(maskPosition.y + maskPixelRect.y),
- width: Math.floor(maskPixelRect.width),
- height: Math.floor(maskPixelRect.height),
- };
-
- // Abort when the canvas is effectively empty—no pixels to extract.
- if (rect.width <= 0 || rect.height <= 0) {
- toast({ status: 'warning', title: t('controlLayers.canvasIsEmpty') });
+ void (async () => {
+ // The active inpaint mask layer is required to build the mask used for extraction.
+ const maskAdapter = canvasManager.getAdapter(entityIdentifier);
+ if (!maskAdapter) {
+ log.error({ entityIdentifier }, 'Inpaint mask adapter not found when extracting masked area');
+ toast({ status: 'error', title: 'Unable to extract masked area.' });
return;
}
- // Gather the visible raster layer adapters so we can composite them into a single bitmap.
- const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
-
- let compositeImageData: ImageData;
- if (rasterAdapters.length === 0) {
- // No visible raster layers—create a transparent buffer that matches the canvas bounds.
- compositeImageData = new ImageData(rect.width, rect.height);
- } else {
- // Render the visible raster layers into an offscreen canvas restricted to the canvas bounds.
- const compositeCanvas = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect);
- compositeImageData = canvasToImageData(compositeCanvas);
- }
-
- // Render the inpaint mask layer into a canvas so we have the alpha data that defines the mask.
- const maskCanvas = maskAdapter.getCanvas(rect);
- const maskImageData = canvasToImageData(maskCanvas);
-
- if (
- maskImageData.width !== compositeImageData.width ||
- maskImageData.height !== compositeImageData.height
- ) {
- // Bail out if the mask and composite buffers disagree on dimensions.
- log.error(
- {
- maskDimensions: { width: maskImageData.width, height: maskImageData.height },
- compositeDimensions: { width: compositeImageData.width, height: compositeImageData.height },
+ try {
+ // Use the mask's bounding box in stage coordinates to constrain the extraction region.
+ const maskPixelRect = maskAdapter.transformer.$pixelRect.get();
+ const maskPosition = maskAdapter.state.position;
+ const rect: Rect = {
+ x: Math.floor(maskPosition.x + maskPixelRect.x),
+ y: Math.floor(maskPosition.y + maskPixelRect.y),
+ width: Math.floor(maskPixelRect.width),
+ height: Math.floor(maskPixelRect.height),
+ };
+
+ // Abort when the canvas is effectively empty—no pixels to extract.
+ if (rect.width <= 0 || rect.height <= 0) {
+ toast({ status: 'warning', title: 'Canvas is empty.' });
+ return;
+ }
+
+ // Gather the visible raster layer adapters so we can composite them into a single bitmap.
+ const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
+
+ let compositeImageData: ImageData;
+ if (rasterAdapters.length === 0) {
+ // No visible raster layers—create a transparent buffer that matches the canvas bounds.
+ compositeImageData = new ImageData(rect.width, rect.height);
+ } else {
+ // Render the visible raster layers into an offscreen canvas restricted to the canvas bounds.
+ const compositeCanvas = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect);
+ compositeImageData = canvasToImageData(compositeCanvas);
+ }
+
+ // Render the inpaint mask layer into a canvas so we have the alpha data that defines the mask.
+ const maskCanvas = maskAdapter.getCanvas(rect);
+ const maskImageData = canvasToImageData(maskCanvas);
+
+ if (
+ maskImageData.width !== compositeImageData.width ||
+ maskImageData.height !== compositeImageData.height
+ ) {
+ // Bail out if the mask and composite buffers disagree on dimensions.
+ log.error(
+ {
+ maskDimensions: { width: maskImageData.width, height: maskImageData.height },
+ compositeDimensions: { width: compositeImageData.width, height: compositeImageData.height },
+ },
+ 'Mask and composite dimensions did not match when extracting masked area'
+ );
+ toast({ status: 'error', title: 'Unable to extract masked area.' });
+ return;
+ }
+
+ const compositeArray = compositeImageData.data;
+ const maskArray = maskImageData.data;
+
+ if (!compositeArray || !maskArray) {
+ toast({ status: 'error', title: 'Cannot extract: image or mask data is missing.' });
+ return;
+ }
+
+ const outputArray = new Uint8ClampedArray(compositeArray.length);
+
+ // Apply the mask alpha channel to each pixel in the composite, keeping RGB untouched and only masking alpha.
+ for (let i = 0; i < compositeArray.length; i += 4) {
+ const maskAlpha = ((maskArray[i + 3] ?? 0) / 255) || 0;
+ outputArray[i] = compositeArray[i] ?? 0;
+ outputArray[i + 1] = compositeArray[i + 1] ?? 0;
+ outputArray[i + 2] = compositeArray[i + 2] ?? 0;
+ outputArray[i + 3] = Math.round((compositeArray[i + 3] ?? 0) * maskAlpha);
+ }
+
+ // Package the masked pixels into an ImageData and draw them to an offscreen canvas.
+ const outputImageData = new ImageData(outputArray, rect.width, rect.height);
+ const outputCanvas = document.createElement('canvas');
+ outputCanvas.width = rect.width;
+ outputCanvas.height = rect.height;
+ const outputContext = outputCanvas.getContext('2d');
+
+ if (!outputContext) {
+ throw new Error('Failed to create canvas context for masked extraction');
+ }
+
+ outputContext.putImageData(outputImageData, 0, 0);
+
+ // Upload the extracted canvas region as a real image resource and returns image_name
+
+ const blob = await canvasToBlob(outputCanvas);
+
+ const imageDTO = await uploadImage({
+ file: new File([blob], 'inpaint-extract.png', { type: 'image/png' }),
+ image_category: 'general',
+ is_intermediate: true,
+ silent: true,
+ });
+
+ // Convert the uploaded image DTO into the canvas image state to avoid serializing the PNG in client state.
+ const imageState: CanvasImageState = imageDTOToImageObject(imageDTO);
+
+ // Insert the new raster layer just after the last existing raster layer so it appears above the mask.
+ const addAfter = canvasManager.stateApi.getRasterLayersState().entities.at(-1)?.id;
+
+ canvasManager.stateApi.addRasterLayer({
+ overrides: {
+ objects: [imageState],
+ position: { x: rect.x, y: rect.y },
},
- 'Mask and composite dimensions did not match when extracting masked area'
- );
- toast({ status: 'error', title: t('controlLayers.extractMaskedAreaError') });
- return;
- }
-
- const compositeArray = compositeImageData.data;
- const maskArray = maskImageData.data;
-
- if (!compositeArray || !maskArray) {
- toast({ status: 'error', title: t('controlLayers.extractMaskedAreaDataMissing') });
- return;
+ isSelected: true,
+ addAfter,
+ });
+ } catch (error) {
+ log.error({ error: serializeError(error as Error) }, 'Failed to extract masked area to raster layer');
+ toast({ status: 'error', title: 'Unable to extract masked area.' });
}
-
- const outputArray = new Uint8ClampedArray(compositeArray.length);
-
- // Apply the mask alpha channel to each pixel in the composite, keeping RGB untouched and only masking alpha.
- for (let i = 0; i < compositeArray.length; i += 4) {
- const maskAlpha = ((maskArray[i + 3] ?? 0) / 255) || 0;
- outputArray[i] = compositeArray[i] ?? 0;
- outputArray[i + 1] = compositeArray[i + 1] ?? 0;
- outputArray[i + 2] = compositeArray[i + 2] ?? 0;
- outputArray[i + 3] = Math.round((compositeArray[i + 3] ?? 0) * maskAlpha);
- }
-
- // Package the masked pixels into an ImageData and draw them to an offscreen canvas.
- const outputImageData = new ImageData(outputArray, rect.width, rect.height);
- const outputCanvas = document.createElement('canvas');
- outputCanvas.width = rect.width;
- outputCanvas.height = rect.height;
- const outputContext = outputCanvas.getContext('2d');
-
- if (!outputContext) {
- throw new Error('Failed to create canvas context for masked extraction');
- }
-
- outputContext.putImageData(outputImageData, 0, 0);
-
- // Convert the offscreen canvas into an Invoke canvas image state for insertion into the layer stack.
- const imageState: CanvasImageState = {
- id: getPrefixedId('image'),
- type: 'image',
- image: {
- dataURL: outputCanvas.toDataURL('image/png'),
- width: rect.width,
- height: rect.height,
- },
- };
-
- // Insert the new raster layer just after the last existing raster layer so it appears above the mask.
- const addAfter = canvasManager.stateApi.getRasterLayersState().entities.at(-1)?.id;
-
- canvasManager.stateApi.addRasterLayer({
- overrides: {
- objects: [imageState],
- position: { x: rect.x, y: rect.y },
- },
- isSelected: true,
- addAfter,
- });
- } catch (error) {
- log.error({ error: serializeError(error as Error) }, 'Failed to extract masked area to raster layer');
- toast({ status: 'error', title: t('controlLayers.extractMaskedAreaError') });
- }
- }, [canvasManager, entityIdentifier, t]);
+ })();
+ }, [canvasManager, entityIdentifier]);
return (
} isDisabled={isBusy}>
From 15285ecc6e7a1476960d4d6b233cf0073f49ea79 Mon Sep 17 00:00:00 2001
From: DustyShoe
Date: Sun, 23 Nov 2025 13:19:48 +0200
Subject: [PATCH 7/8] Fix formatting issue in
InpaintMaskMenuItemsExtractMaskedArea.tsx
---
.../InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
index 9b243200f37..da640e853b1 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -65,10 +65,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
const maskCanvas = maskAdapter.getCanvas(rect);
const maskImageData = canvasToImageData(maskCanvas);
- if (
- maskImageData.width !== compositeImageData.width ||
- maskImageData.height !== compositeImageData.height
- ) {
+ if (maskImageData.width !== compositeImageData.width || maskImageData.height !== compositeImageData.height) {
// Bail out if the mask and composite buffers disagree on dimensions.
log.error(
{
@@ -93,7 +90,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
// Apply the mask alpha channel to each pixel in the composite, keeping RGB untouched and only masking alpha.
for (let i = 0; i < compositeArray.length; i += 4) {
- const maskAlpha = ((maskArray[i + 3] ?? 0) / 255) || 0;
+ const maskAlpha = (maskArray[i + 3] ?? 0) / 255 || 0;
outputArray[i] = compositeArray[i] ?? 0;
outputArray[i + 1] = compositeArray[i + 1] ?? 0;
outputArray[i + 2] = compositeArray[i + 2] ?? 0;
@@ -153,4 +150,3 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
});
InpaintMaskMenuItemsExtractMaskedArea.displayName = 'InpaintMaskMenuItemsExtractMaskedArea';
-
From aa7653813d1d3488053a9779aa8a864e5d0383a7 Mon Sep 17 00:00:00 2001
From: DustyShoe
Date: Sun, 23 Nov 2025 15:35:35 +0200
Subject: [PATCH 8/8] Minnor comment fix
---
.../InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
index da640e853b1..fccee3a3a0e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -124,7 +124,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
// Convert the uploaded image DTO into the canvas image state to avoid serializing the PNG in client state.
const imageState: CanvasImageState = imageDTOToImageObject(imageDTO);
- // Insert the new raster layer just after the last existing raster layer so it appears above the mask.
+ // Insert the new raster layer so it appears at the top of raster layer goup.
const addAfter = canvasManager.stateApi.getRasterLayersState().entities.at(-1)?.id;
canvasManager.stateApi.addRasterLayer({