mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui):invert mask
This commit is contained in:
committed by
psychedelicious
parent
7640ee307c
commit
c490e0ce08
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
@@ -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 (
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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+]']);
|
||||
|
||||
Reference in New Issue
Block a user