From 0c43d26d41624e798cf297563243fbbf9df79409 Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Thu, 13 Nov 2025 22:05:06 +0200
Subject: [PATCH 1/8] feat: extract masked raster content
---
.../InpaintMask/InpaintMaskMenuItems.tsx | 2 +
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 134 ++++++++++++++++++
2 files changed, 136 insertions(+)
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
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..d0e93546226
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -0,0 +1,134 @@
+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 { 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 onExtract = useCallback(async () => {
+ 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;
+ }
+
+ try {
+ const bbox = canvasManager.stateApi.getBbox();
+ const rect: Rect = {
+ x: Math.floor(bbox.rect.x),
+ y: Math.floor(bbox.rect.y),
+ width: Math.floor(bbox.rect.width),
+ height: Math.floor(bbox.rect.height),
+ };
+
+ if (rect.width <= 0 || rect.height <= 0) {
+ toast({ status: 'warning', title: 'Canvas is empty' });
+ return;
+ }
+
+ const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
+
+ let compositeImageData: ImageData;
+ if (rasterAdapters.length === 0) {
+ compositeImageData = new ImageData(rect.width, rect.height);
+ } else {
+ const compositeCanvas = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect);
+ compositeImageData = canvasToImageData(compositeCanvas);
+ }
+
+ const maskCanvas = maskAdapter.getCanvas(rect);
+ const maskImageData = canvasToImageData(maskCanvas);
+
+ if (
+ maskImageData.width !== compositeImageData.width ||
+ maskImageData.height !== compositeImageData.height
+ ) {
+ 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 outputArray = new Uint8ClampedArray(compositeImageData.data.length);
+ const compositeArray = compositeImageData.data;
+ const maskArray = maskImageData.data;
+
+ for (let i = 0; i < compositeArray.length; i += 4) {
+ const maskAlpha = maskArray[i + 3] / 255;
+
+ outputArray[i] = Math.round(compositeArray[i] * maskAlpha);
+ outputArray[i + 1] = Math.round(compositeArray[i + 1] * maskAlpha);
+ outputArray[i + 2] = Math.round(compositeArray[i + 2] * maskAlpha);
+ outputArray[i + 3] = Math.round(compositeArray[i + 3] * maskAlpha);
+ }
+
+ 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);
+
+ const imageState: CanvasImageState = {
+ id: getPrefixedId('image'),
+ type: 'image',
+ image: {
+ dataURL: outputCanvas.toDataURL('image/png'),
+ width: rect.width,
+ height: rect.height,
+ },
+ };
+
+ 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: 'Unable to extract masked area' });
+ }
+ }, [canvasManager, entityIdentifier]);
+
+ return (
+ }
+ isDisabled={isBusy}
+ >
+ Extract masked area to new layer
+
+ );
+});
+
+InpaintMaskMenuItemsExtractMaskedArea.displayName = 'InpaintMaskMenuItemsExtractMaskedArea';
+
From 04327ab05786adf633c10635645e57aada4f3c26 Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Sat, 15 Nov 2025 09:33:11 +0200
Subject: [PATCH 2/8] docs: clarify mask extraction workflow
---
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 12 ++++++++++++
1 file changed, 12 insertions(+)
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 d0e93546226..5ff1d18486e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -19,6 +19,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
const isBusy = useCanvasIsBusy();
const onExtract = useCallback(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');
@@ -27,6 +28,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
}
try {
+ // Get the canvas bounding box so the raster extraction respects the visible canvas bounds.
const bbox = canvasManager.stateApi.getBbox();
const rect: Rect = {
x: Math.floor(bbox.rect.x),
@@ -35,21 +37,26 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
height: Math.floor(bbox.rect.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);
@@ -57,6 +64,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
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 },
@@ -72,6 +80,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
const compositeArray = compositeImageData.data;
const maskArray = maskImageData.data;
+ // Apply the mask alpha channel to each pixel in the composite, keeping RGB but zeroing alpha outside the mask.
for (let i = 0; i < compositeArray.length; i += 4) {
const maskAlpha = maskArray[i + 3] / 255;
@@ -81,6 +90,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
outputArray[i + 3] = Math.round(compositeArray[i + 3] * 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;
@@ -93,6 +103,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
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',
@@ -103,6 +114,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
},
};
+ // 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({
From 35fb1458237acc177d5be9ffe82d27d9c505148d Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Sat, 15 Nov 2025 09:42:56 +0200
Subject: [PATCH 3/8] fix: guard mask extraction data access
---
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 12 ++++++++----
1 file changed, 8 insertions(+), 4 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 5ff1d18486e..498adc37328 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -77,13 +77,17 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
}
const outputArray = new Uint8ClampedArray(compositeImageData.data.length);
- const compositeArray = compositeImageData.data;
- const maskArray = maskImageData.data;
+ const compositeArray = compositeImageData.data!;
+ const maskArray = maskImageData.data!;
+
+ if (!compositeArray || !maskArray) {
+ toast({ status: 'error', title: 'Cannot extract: image or mask data is missing.' });
+ return;
+ }
// Apply the mask alpha channel to each pixel in the composite, keeping RGB but zeroing alpha outside the mask.
for (let i = 0; i < compositeArray.length; i += 4) {
- const maskAlpha = maskArray[i + 3] / 255;
-
+ const maskAlpha = maskArray[i + 3] ? maskArray[i + 3] / 255 : 0;
outputArray[i] = Math.round(compositeArray[i] * maskAlpha);
outputArray[i + 1] = Math.round(compositeArray[i + 1] * maskAlpha);
outputArray[i + 2] = Math.round(compositeArray[i + 2] * maskAlpha);
From c4bade127df3b5eefd65a798bfa381fc2f2064d4 Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Sat, 15 Nov 2025 11:56:10 +0200
Subject: [PATCH 4/8] fix: satisfy strict indexed access in mask extraction
---
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 14 +++++++-------
1 file changed, 7 insertions(+), 7 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 498adc37328..fde87455dd0 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -77,8 +77,8 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
}
const outputArray = new Uint8ClampedArray(compositeImageData.data.length);
- const compositeArray = compositeImageData.data!;
- const maskArray = maskImageData.data!;
+ const compositeArray = compositeImageData.data;
+ const maskArray = maskImageData.data;
if (!compositeArray || !maskArray) {
toast({ status: 'error', title: 'Cannot extract: image or mask data is missing.' });
@@ -87,11 +87,11 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
// Apply the mask alpha channel to each pixel in the composite, keeping RGB but zeroing alpha outside the mask.
for (let i = 0; i < compositeArray.length; i += 4) {
- const maskAlpha = maskArray[i + 3] ? maskArray[i + 3] / 255 : 0;
- outputArray[i] = Math.round(compositeArray[i] * maskAlpha);
- outputArray[i + 1] = Math.round(compositeArray[i + 1] * maskAlpha);
- outputArray[i + 2] = Math.round(compositeArray[i + 2] * maskAlpha);
- outputArray[i + 3] = Math.round(compositeArray[i + 3] * maskAlpha);
+ const maskAlpha = (maskArray[i + 3] ?? 0) / 255;
+ outputArray[i] = Math.round((compositeArray[i] ?? 0) * maskAlpha);
+ outputArray[i + 1] = Math.round((compositeArray[i + 1] ?? 0) * maskAlpha);
+ outputArray[i + 2] = Math.round((compositeArray[i + 2] ?? 0) * maskAlpha);
+ outputArray[i + 3] = Math.round((compositeArray[i + 3] ?? 0) * maskAlpha);
}
// Package the masked pixels into an ImageData and draw them to an offscreen canvas.
From c5b24daac1c457c0bc4a46955c44dba5391ca5a0 Mon Sep 17 00:00:00 2001
From: DustyShoe <38873282+DustyShoe@users.noreply.github.com>
Date: Sat, 15 Nov 2025 12:24:52 +0200
Subject: [PATCH 5/8] fix: assert mask extraction arrays
---
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 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 fde87455dd0..20b63968e1e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -18,7 +18,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
const isBusy = useCanvasIsBusy();
- const onExtract = useCallback(async () => {
+ const onExtract = useCallback(() => {
// The active inpaint mask layer is required to build the mask used for extraction.
const maskAdapter = canvasManager.getAdapter(entityIdentifier);
if (!maskAdapter) {
@@ -76,7 +76,6 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
return;
}
- const outputArray = new Uint8ClampedArray(compositeImageData.data.length);
const compositeArray = compositeImageData.data;
const maskArray = maskImageData.data;
@@ -85,13 +84,15 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
return;
}
+ const outputArray = new Uint8ClampedArray(compositeArray.length);
+
// Apply the mask alpha channel to each pixel in the composite, keeping RGB but zeroing alpha outside the mask.
for (let i = 0; i < compositeArray.length; i += 4) {
- const maskAlpha = (maskArray[i + 3] ?? 0) / 255;
- outputArray[i] = Math.round((compositeArray[i] ?? 0) * maskAlpha);
- outputArray[i + 1] = Math.round((compositeArray[i + 1] ?? 0) * maskAlpha);
- outputArray[i + 2] = Math.round((compositeArray[i + 2] ?? 0) * maskAlpha);
- outputArray[i + 3] = Math.round((compositeArray[i + 3] ?? 0) * maskAlpha);
+ const maskAlpha = maskArray[i + 3] ? maskArray[i + 3]! / 255 : 0;
+ outputArray[i] = Math.round(compositeArray[i]! * maskAlpha);
+ outputArray[i + 1] = Math.round(compositeArray[i + 1]! * maskAlpha);
+ outputArray[i + 2] = Math.round(compositeArray[i + 2]! * maskAlpha);
+ outputArray[i + 3] = Math.round(compositeArray[i + 3]! * maskAlpha);
}
// Package the masked pixels into an ImageData and draw them to an offscreen canvas.
From 6c0fd3e14ab1048c1cd489c1f61bcfb38a08edd7 Mon Sep 17 00:00:00 2001
From: DustyShoe
Date: Sat, 15 Nov 2025 15:58:55 +0200
Subject: [PATCH 6/8] Fix mask extraction implementation
---
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 15 +++------------
1 file changed, 3 insertions(+), 12 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 20b63968e1e..2862f2946d0 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 { PiSelectionBackgroundBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
-import { toast } from 'features/toast/toast';
-
const log = logger('canvas');
export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
@@ -60,10 +59,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(
{
@@ -137,15 +133,10 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
}, [canvasManager, entityIdentifier]);
return (
- }
- isDisabled={isBusy}
- >
+ } isDisabled={isBusy}>
Extract masked area to new layer
);
});
InpaintMaskMenuItemsExtractMaskedArea.displayName = 'InpaintMaskMenuItemsExtractMaskedArea';
-
From 557b57f312baf74c1be784c4eccdf4511760c290 Mon Sep 17 00:00:00 2001
From: DustyShoe
Date: Sat, 15 Nov 2025 20:38:46 +0200
Subject: [PATCH 7/8] Fix alpha masking to remove fringe
---
.../InpaintMaskMenuItemsExtractMaskedArea.tsx | 50 +++++++++++++------
1 file changed, 34 insertions(+), 16 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 2862f2946d0..b47a640ef5a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -59,36 +59,54 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
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.
+ // Ensure both composite and mask image data exist and agree on dimensions.
+ if (
+ !compositeImageData ||
+ !maskImageData ||
+ maskImageData.width !== compositeImageData.width ||
+ maskImageData.height !== compositeImageData.height
+ ) {
log.error(
{
- maskDimensions: { width: maskImageData.width, height: maskImageData.height },
- compositeDimensions: { width: compositeImageData.width, height: compositeImageData.height },
+ hasComposite: !!compositeImageData,
+ hasMask: !!maskImageData,
+ maskDimensions: maskImageData ? { width: maskImageData.width, height: maskImageData.height } : null,
+ compositeDimensions: compositeImageData
+ ? { width: compositeImageData.width, height: compositeImageData.height }
+ : null,
},
- 'Mask and composite dimensions did not match when extracting masked area'
+ 'Mask and composite dimensions did not match or image data missing when extracting masked area'
);
toast({ status: 'error', title: 'Unable to extract masked area' });
return;
}
+ // At this point both image buffers are guaranteed to be valid and dimensionally aligned.
const compositeArray = compositeImageData.data;
const maskArray = maskImageData.data;
- if (!compositeArray || !maskArray) {
- toast({ status: 'error', title: 'Cannot extract: image or mask data is missing.' });
- return;
- }
-
+ // Prepare output pixel buffer.
const outputArray = new Uint8ClampedArray(compositeArray.length);
- // Apply the mask alpha channel to each pixel in the composite, keeping RGB but zeroing alpha outside the mask.
+ // Apply the mask alpha only to the alpha channel.
+ // Do NOT multiply RGB by maskAlpha to avoid dark fringe artifacts around mask edges.
for (let i = 0; i < compositeArray.length; i += 4) {
- const maskAlpha = maskArray[i + 3] ? maskArray[i + 3]! / 255 : 0;
- outputArray[i] = Math.round(compositeArray[i]! * maskAlpha);
- outputArray[i + 1] = Math.round(compositeArray[i + 1]! * maskAlpha);
- outputArray[i + 2] = Math.round(compositeArray[i + 2]! * maskAlpha);
- outputArray[i + 3] = Math.round(compositeArray[i + 3]! * maskAlpha);
+ // Read original composite pixel, defaulting to 0 to satisfy strict indexed access rules.
+ const r = compositeArray[i] ?? 0;
+ const g = compositeArray[i + 1] ?? 0;
+ const b = compositeArray[i + 2] ?? 0;
+ const a = compositeArray[i + 3] ?? 0;
+
+ // Extract mask alpha (0..255 → 0..1).
+ const maskAlpha = (maskArray[i + 3] ?? 0) / 255;
+
+ // Preserve original RGB values.
+ outputArray[i] = r;
+ outputArray[i + 1] = g;
+ outputArray[i + 2] = b;
+
+ // Mask only the alpha channel to avoid halo artifacts.
+ outputArray[i + 3] = Math.round(a * maskAlpha);
}
// Package the masked pixels into an ImageData and draw them to an offscreen canvas.
From 862e330e78d6c0a198a2492bee276a05aa1a482b Mon Sep 17 00:00:00 2001
From: DustyShoe
Date: Sun, 16 Nov 2025 13:00:38 +0200
Subject: [PATCH 8/8] Clarify layer insertion comment for mask extraction
---
.../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 b47a640ef5a..562fd4366ad 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsExtractMaskedArea.tsx
@@ -133,7 +133,7 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
},
};
- // Insert the new raster layer just after the last existing raster layer so it appears above the mask.
+ // Insert the new raster layer at the top of the raster layer stack.
const addAfter = canvasManager.stateApi.getRasterLayersState().entities.at(-1)?.id;
canvasManager.stateApi.addRasterLayer({