feat(ui):invert mask

This commit is contained in:
Kent Keirsey
2025-07-20 10:35:54 -04:00
committed by psychedelicious
parent 7640ee307c
commit c490e0ce08
7 changed files with 207 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import { EntityListGlobalActionBarAddLayerMenu } from 'features/controlLayers/co
import { EntityListSelectedEntityActionBarDuplicateButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton';
import { EntityListSelectedEntityActionBarFill } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill';
import { EntityListSelectedEntityActionBarFilterButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton';
import { EntityListSelectedEntityActionBarInvertMaskButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarInvertMaskButton';
import { EntityListSelectedEntityActionBarOpacity } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity';
import { EntityListSelectedEntityActionBarSelectObjectButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton';
import { EntityListSelectedEntityActionBarTransformButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton';
@@ -21,6 +22,7 @@ export const EntityListSelectedEntityActionBar = memo(() => {
<EntityListSelectedEntityActionBarSelectObjectButton />
<EntityListSelectedEntityActionBarFilterButton />
<EntityListSelectedEntityActionBarTransformButton />
<EntityListSelectedEntityActionBarInvertMaskButton />
<EntityListSelectedEntityActionBarSaveToAssetsButton />
<EntityListSelectedEntityActionBarDuplicateButton />
<EntityListNonRasterLayerToggle />

View File

@@ -0,0 +1,39 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useInvertMask } from 'features/controlLayers/hooks/useInvertMask';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { isInpaintMaskEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCheckerboardDuotone } from 'react-icons/pi';
export const EntityListSelectedEntityActionBarInvertMaskButton = memo(() => {
const { t } = useTranslation();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isBusy = useCanvasIsBusy();
const invertMask = useInvertMask();
if (!selectedEntityIdentifier) {
return null;
}
if (!isInpaintMaskEntityIdentifier(selectedEntityIdentifier)) {
return null;
}
return (
<IconButton
onClick={invertMask}
isDisabled={isBusy}
minW={8}
variant="link"
alignSelf="stretch"
aria-label={t('controlLayers.invertMask')}
tooltip={t('controlLayers.invertMask')}
icon={<PiCheckerboardDuotone />}
/>
);
});
EntityListSelectedEntityActionBarInvertMaskButton.displayName = 'EntityListSelectedEntityActionBarInvertMaskButton';

View File

@@ -13,6 +13,7 @@ import { CanvasToolbarUndoButton } from 'features/controlLayers/components/Toolb
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey';
import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey';
import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey';
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey';
import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey';
@@ -28,6 +29,7 @@ export const CanvasToolbar = memo(() => {
useNextPrevEntityHotkeys();
useCanvasTransformHotkey();
useCanvasFilterHotkey();
useCanvasInvertMaskHotkey();
useCanvasToggleNonRasterLayersHotkey();
return (

View File

@@ -0,0 +1,36 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useInvertMask } from 'features/controlLayers/hooks/useInvertMask';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { isInpaintMaskEntityIdentifier } from 'features/controlLayers/store/types';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useMemo } from 'react';
export const useCanvasInvertMaskHotkey = () => {
useAssertSingleton('useCanvasInvertMaskHotkey');
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isBusy = useCanvasIsBusy();
const invertMask = useInvertMask();
const isEnabled = useMemo(() => {
if (!selectedEntityIdentifier) {
return false;
}
if (!isInpaintMaskEntityIdentifier(selectedEntityIdentifier)) {
return false;
}
if (isBusy) {
return false;
}
return true;
}, [selectedEntityIdentifier, isBusy]);
useRegisteredHotkeys({
id: 'invertMask',
category: 'canvas',
callback: invertMask,
options: { enabled: isEnabled, preventDefault: true },
dependencies: [invertMask, isEnabled],
});
};

View File

@@ -0,0 +1,113 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { canvasToBlob, canvasToImageData } from 'features/controlLayers/konva/util';
import { entityRasterized } from 'features/controlLayers/store/canvasSlice';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { uploadImage } from 'services/api/endpoints/images';
export const useInvertMask = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const invertMask = useCallback(async () => {
try {
const adapters = canvasManager.compositor.getVisibleAdaptersOfType('inpaint_mask');
if (adapters.length === 0) {
toast({
id: 'NO_VISIBLE_MASKS',
title: t('toast.noVisibleMasks'),
description: t('toast.noVisibleMasksDesc'),
status: 'warning',
});
return;
}
const fullCanvas = document.createElement('canvas');
fullCanvas.width = bboxRect.width;
fullCanvas.height = bboxRect.height;
const fullCtx = fullCanvas.getContext('2d');
if (!fullCtx) {
throw new Error('Failed to get canvas context');
}
// Fill with transparent black (no mask)
fullCtx.fillStyle = 'rgba(0, 0, 0, 0)';
fullCtx.fillRect(0, 0, bboxRect.width, bboxRect.height);
// Get the visible masks rect
const visibleMasksRect = canvasManager.compositor.getVisibleRectOfType('inpaint_mask');
// Only composite if there's a visible rect
if (visibleMasksRect.width > 0 && visibleMasksRect.height > 0) {
// Get composite of masks in their original position
const compositeCanvas = canvasManager.compositor.getCompositeCanvas(adapters, visibleMasksRect);
// Draw the composite onto the full canvas at the correct position
const offsetX = visibleMasksRect.x - bboxRect.x;
const offsetY = visibleMasksRect.y - bboxRect.y;
fullCtx.drawImage(compositeCanvas, offsetX, offsetY);
}
// Get image data and invert
const imageData = canvasToImageData(fullCanvas);
const data = imageData.data;
// Invert alpha values (where current masks are opaque, inverted mask will be transparent)
for (let i = 3; i < data.length; i += 4) {
data[i] = 255 - data[i]; // Invert alpha
}
// Put the inverted data back
fullCtx.putImageData(imageData, 0, 0);
// Convert to blob and upload
const blob = await canvasToBlob(fullCanvas);
const imageDTO = await uploadImage({
file: new File([blob], 'inverted-mask.png', { type: 'image/png' }),
image_category: 'general',
is_intermediate: true,
silent: true,
});
// Create image object from the inverted mask
const imageObject = imageDTOToImageObject(imageDTO);
// Replace the selected mask's objects with the inverted mask
if (selectedEntityIdentifier) {
dispatch(
entityRasterized({
entityIdentifier: selectedEntityIdentifier,
imageObject,
position: { x: bboxRect.x, y: bboxRect.y },
replaceObjects: true,
isSelected: true,
})
);
}
toast({
id: 'MASK_INVERTED',
title: t('toast.maskInverted'),
status: 'success',
});
} catch (error) {
toast({
id: 'MASK_INVERT_FAILED',
title: t('toast.maskInvertFailed'),
description: String(error),
status: 'error',
});
}
}, [canvasManager, dispatch, selectedEntityIdentifier, t]);
return invertMask;
};

View File

@@ -112,6 +112,7 @@ export const useHotkeyData = (): HotkeysData => {
addHotkey('canvas', 'resetSelected', ['shift+c']);
addHotkey('canvas', 'transformSelected', ['shift+t']);
addHotkey('canvas', 'filterSelected', ['shift+f']);
addHotkey('canvas', 'invertMask', ['shift+v']);
addHotkey('canvas', 'undo', ['mod+z']);
addHotkey('canvas', 'redo', ['mod+shift+z', 'mod+y']);
addHotkey('canvas', 'nextEntity', ['alt+]']);