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({