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