@@ -3,13 +3,15 @@ import { logger } from 'app/logging/logger';
33import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate' ;
44import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext' ;
55import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy' ;
6- import { canvasToImageData , getPrefixedId } from 'features/controlLayers/konva/util' ;
6+ import { canvasToBlob , canvasToImageData } from 'features/controlLayers/konva/util' ;
77import type { CanvasImageState , Rect } from 'features/controlLayers/store/types' ;
8+ import { imageDTOToImageObject } from 'features/controlLayers/store/util' ;
89import { toast } from 'features/toast/toast' ;
910import { memo , useCallback } from 'react' ;
1011import { useTranslation } from 'react-i18next' ;
1112import { PiSelectionBackgroundBold } from 'react-icons/pi' ;
1213import { serializeError } from 'serialize-error' ;
14+ import { uploadImage } from 'services/api/endpoints/images' ;
1315
1416const log = logger ( 'canvas' ) ;
1517
@@ -20,123 +22,128 @@ export const InpaintMaskMenuItemsExtractMaskedArea = memo(() => {
2022 const { t } = useTranslation ( ) ;
2123
2224 const onExtract = useCallback ( ( ) => {
23- // The active inpaint mask layer is required to build the mask used for extraction.
24- const maskAdapter = canvasManager . getAdapter ( entityIdentifier ) ;
25- if ( ! maskAdapter ) {
26- log . error ( { entityIdentifier } , 'Inpaint mask adapter not found when extracting masked area' ) ;
27- toast ( { status : 'error' , title : t ( 'controlLayers.extractMaskedAreaError' ) } ) ;
28- return ;
29- }
30-
31- try {
32- // Use the mask's bounding box in stage coordinates to constrain the extraction region.
33- const maskPixelRect = maskAdapter . transformer . $pixelRect . get ( ) ;
34- const maskPosition = maskAdapter . state . position ;
35- const rect : Rect = {
36- x : Math . floor ( maskPosition . x + maskPixelRect . x ) ,
37- y : Math . floor ( maskPosition . y + maskPixelRect . y ) ,
38- width : Math . floor ( maskPixelRect . width ) ,
39- height : Math . floor ( maskPixelRect . height ) ,
40- } ;
41-
42- // Abort when the canvas is effectively empty—no pixels to extract.
43- if ( rect . width <= 0 || rect . height <= 0 ) {
44- toast ( { status : 'warning' , title : t ( 'controlLayers.canvasIsEmpty' ) } ) ;
25+ void ( async ( ) => {
26+ // The active inpaint mask layer is required to build the mask used for extraction.
27+ const maskAdapter = canvasManager . getAdapter ( entityIdentifier ) ;
28+ if ( ! maskAdapter ) {
29+ log . error ( { entityIdentifier } , 'Inpaint mask adapter not found when extracting masked area' ) ;
30+ toast ( { status : 'error' , title : 'Unable to extract masked area.' } ) ;
4531 return ;
4632 }
4733
48- // Gather the visible raster layer adapters so we can composite them into a single bitmap.
49- const rasterAdapters = canvasManager . compositor . getVisibleAdaptersOfType ( 'raster_layer' ) ;
50-
51- let compositeImageData : ImageData ;
52- if ( rasterAdapters . length === 0 ) {
53- // No visible raster layers—create a transparent buffer that matches the canvas bounds.
54- compositeImageData = new ImageData ( rect . width , rect . height ) ;
55- } else {
56- // Render the visible raster layers into an offscreen canvas restricted to the canvas bounds.
57- const compositeCanvas = canvasManager . compositor . getCompositeCanvas ( rasterAdapters , rect ) ;
58- compositeImageData = canvasToImageData ( compositeCanvas ) ;
59- }
60-
61- // Render the inpaint mask layer into a canvas so we have the alpha data that defines the mask.
62- const maskCanvas = maskAdapter . getCanvas ( rect ) ;
63- const maskImageData = canvasToImageData ( maskCanvas ) ;
64-
65- if (
66- maskImageData . width !== compositeImageData . width ||
67- maskImageData . height !== compositeImageData . height
68- ) {
69- // Bail out if the mask and composite buffers disagree on dimensions.
70- log . error (
71- {
72- maskDimensions : { width : maskImageData . width , height : maskImageData . height } ,
73- compositeDimensions : { width : compositeImageData . width , height : compositeImageData . height } ,
34+ try {
35+ // Use the mask's bounding box in stage coordinates to constrain the extraction region.
36+ const maskPixelRect = maskAdapter . transformer . $pixelRect . get ( ) ;
37+ const maskPosition = maskAdapter . state . position ;
38+ const rect : Rect = {
39+ x : Math . floor ( maskPosition . x + maskPixelRect . x ) ,
40+ y : Math . floor ( maskPosition . y + maskPixelRect . y ) ,
41+ width : Math . floor ( maskPixelRect . width ) ,
42+ height : Math . floor ( maskPixelRect . height ) ,
43+ } ;
44+
45+ // Abort when the canvas is effectively empty—no pixels to extract.
46+ if ( rect . width <= 0 || rect . height <= 0 ) {
47+ toast ( { status : 'warning' , title : 'Canvas is empty.' } ) ;
48+ return ;
49+ }
50+
51+ // Gather the visible raster layer adapters so we can composite them into a single bitmap.
52+ const rasterAdapters = canvasManager . compositor . getVisibleAdaptersOfType ( 'raster_layer' ) ;
53+
54+ let compositeImageData : ImageData ;
55+ if ( rasterAdapters . length === 0 ) {
56+ // No visible raster layers—create a transparent buffer that matches the canvas bounds.
57+ compositeImageData = new ImageData ( rect . width , rect . height ) ;
58+ } else {
59+ // Render the visible raster layers into an offscreen canvas restricted to the canvas bounds.
60+ const compositeCanvas = canvasManager . compositor . getCompositeCanvas ( rasterAdapters , rect ) ;
61+ compositeImageData = canvasToImageData ( compositeCanvas ) ;
62+ }
63+
64+ // Render the inpaint mask layer into a canvas so we have the alpha data that defines the mask.
65+ const maskCanvas = maskAdapter . getCanvas ( rect ) ;
66+ const maskImageData = canvasToImageData ( maskCanvas ) ;
67+
68+ if (
69+ maskImageData . width !== compositeImageData . width ||
70+ maskImageData . height !== compositeImageData . height
71+ ) {
72+ // Bail out if the mask and composite buffers disagree on dimensions.
73+ log . error (
74+ {
75+ maskDimensions : { width : maskImageData . width , height : maskImageData . height } ,
76+ compositeDimensions : { width : compositeImageData . width , height : compositeImageData . height } ,
77+ } ,
78+ 'Mask and composite dimensions did not match when extracting masked area'
79+ ) ;
80+ toast ( { status : 'error' , title : 'Unable to extract masked area.' } ) ;
81+ return ;
82+ }
83+
84+ const compositeArray = compositeImageData . data ;
85+ const maskArray = maskImageData . data ;
86+
87+ if ( ! compositeArray || ! maskArray ) {
88+ toast ( { status : 'error' , title : 'Cannot extract: image or mask data is missing.' } ) ;
89+ return ;
90+ }
91+
92+ const outputArray = new Uint8ClampedArray ( compositeArray . length ) ;
93+
94+ // Apply the mask alpha channel to each pixel in the composite, keeping RGB untouched and only masking alpha.
95+ for ( let i = 0 ; i < compositeArray . length ; i += 4 ) {
96+ const maskAlpha = ( ( maskArray [ i + 3 ] ?? 0 ) / 255 ) || 0 ;
97+ outputArray [ i ] = compositeArray [ i ] ?? 0 ;
98+ outputArray [ i + 1 ] = compositeArray [ i + 1 ] ?? 0 ;
99+ outputArray [ i + 2 ] = compositeArray [ i + 2 ] ?? 0 ;
100+ outputArray [ i + 3 ] = Math . round ( ( compositeArray [ i + 3 ] ?? 0 ) * maskAlpha ) ;
101+ }
102+
103+ // Package the masked pixels into an ImageData and draw them to an offscreen canvas.
104+ const outputImageData = new ImageData ( outputArray , rect . width , rect . height ) ;
105+ const outputCanvas = document . createElement ( 'canvas' ) ;
106+ outputCanvas . width = rect . width ;
107+ outputCanvas . height = rect . height ;
108+ const outputContext = outputCanvas . getContext ( '2d' ) ;
109+
110+ if ( ! outputContext ) {
111+ throw new Error ( 'Failed to create canvas context for masked extraction' ) ;
112+ }
113+
114+ outputContext . putImageData ( outputImageData , 0 , 0 ) ;
115+
116+ // Upload the extracted canvas region as a real image resource and returns image_name
117+
118+ const blob = await canvasToBlob ( outputCanvas ) ;
119+
120+ const imageDTO = await uploadImage ( {
121+ file : new File ( [ blob ] , 'inpaint-extract.png' , { type : 'image/png' } ) ,
122+ image_category : 'general' ,
123+ is_intermediate : true ,
124+ silent : true ,
125+ } ) ;
126+
127+ // Convert the uploaded image DTO into the canvas image state to avoid serializing the PNG in client state.
128+ const imageState : CanvasImageState = imageDTOToImageObject ( imageDTO ) ;
129+
130+ // Insert the new raster layer just after the last existing raster layer so it appears above the mask.
131+ const addAfter = canvasManager . stateApi . getRasterLayersState ( ) . entities . at ( - 1 ) ?. id ;
132+
133+ canvasManager . stateApi . addRasterLayer ( {
134+ overrides : {
135+ objects : [ imageState ] ,
136+ position : { x : rect . x , y : rect . y } ,
74137 } ,
75- 'Mask and composite dimensions did not match when extracting masked area'
76- ) ;
77- toast ( { status : 'error' , title : t ( 'controlLayers.extractMaskedAreaError' ) } ) ;
78- return ;
79- }
80-
81- const compositeArray = compositeImageData . data ;
82- const maskArray = maskImageData . data ;
83-
84- if ( ! compositeArray || ! maskArray ) {
85- toast ( { status : 'error' , title : t ( 'controlLayers.extractMaskedAreaDataMissing' ) } ) ;
86- return ;
138+ isSelected : true ,
139+ addAfter,
140+ } ) ;
141+ } catch ( error ) {
142+ log . error ( { error : serializeError ( error as Error ) } , 'Failed to extract masked area to raster layer' ) ;
143+ toast ( { status : 'error' , title : 'Unable to extract masked area.' } ) ;
87144 }
88-
89- const outputArray = new Uint8ClampedArray ( compositeArray . length ) ;
90-
91- // Apply the mask alpha channel to each pixel in the composite, keeping RGB untouched and only masking alpha.
92- for ( let i = 0 ; i < compositeArray . length ; i += 4 ) {
93- const maskAlpha = ( ( maskArray [ i + 3 ] ?? 0 ) / 255 ) || 0 ;
94- outputArray [ i ] = compositeArray [ i ] ?? 0 ;
95- outputArray [ i + 1 ] = compositeArray [ i + 1 ] ?? 0 ;
96- outputArray [ i + 2 ] = compositeArray [ i + 2 ] ?? 0 ;
97- outputArray [ i + 3 ] = Math . round ( ( compositeArray [ i + 3 ] ?? 0 ) * maskAlpha ) ;
98- }
99-
100- // Package the masked pixels into an ImageData and draw them to an offscreen canvas.
101- const outputImageData = new ImageData ( outputArray , rect . width , rect . height ) ;
102- const outputCanvas = document . createElement ( 'canvas' ) ;
103- outputCanvas . width = rect . width ;
104- outputCanvas . height = rect . height ;
105- const outputContext = outputCanvas . getContext ( '2d' ) ;
106-
107- if ( ! outputContext ) {
108- throw new Error ( 'Failed to create canvas context for masked extraction' ) ;
109- }
110-
111- outputContext . putImageData ( outputImageData , 0 , 0 ) ;
112-
113- // Convert the offscreen canvas into an Invoke canvas image state for insertion into the layer stack.
114- const imageState : CanvasImageState = {
115- id : getPrefixedId ( 'image' ) ,
116- type : 'image' ,
117- image : {
118- dataURL : outputCanvas . toDataURL ( 'image/png' ) ,
119- width : rect . width ,
120- height : rect . height ,
121- } ,
122- } ;
123-
124- // Insert the new raster layer just after the last existing raster layer so it appears above the mask.
125- const addAfter = canvasManager . stateApi . getRasterLayersState ( ) . entities . at ( - 1 ) ?. id ;
126-
127- canvasManager . stateApi . addRasterLayer ( {
128- overrides : {
129- objects : [ imageState ] ,
130- position : { x : rect . x , y : rect . y } ,
131- } ,
132- isSelected : true ,
133- addAfter,
134- } ) ;
135- } catch ( error ) {
136- log . error ( { error : serializeError ( error as Error ) } , 'Failed to extract masked area to raster layer' ) ;
137- toast ( { status : 'error' , title : t ( 'controlLayers.extractMaskedAreaError' ) } ) ;
138- }
139- } , [ canvasManager , entityIdentifier , t ] ) ;
145+ } ) ( ) ;
146+ } , [ canvasManager , entityIdentifier ] ) ;
140147
141148 return (
142149 < MenuItem onClick = { onExtract } icon = { < PiSelectionBackgroundBold /> } isDisabled = { isBusy } >
0 commit comments