feat(ui): mask fill patterns

This commit is contained in:
psychedelicious 2024-08-16 18:56:26 +10:00
parent c7ba7ac876
commit 6eae3470cd
16 changed files with 283 additions and 61 deletions

View File

@ -1703,6 +1703,15 @@
"filter": "Filter", "filter": "Filter",
"convertToControlLayer": "Convert to Control Layer", "convertToControlLayer": "Convert to Control Layer",
"convertToRasterLayer": "Convert to Raster Layer", "convertToRasterLayer": "Convert to Raster Layer",
"fill": {
"fillStyle": "Fill Style",
"solid": "Solid",
"grid": "Grid",
"crosshatch": "Crosshatch",
"vertical": "Vertical",
"horizontal": "Horizontal",
"diagonal": "Diagonal"
},
"tool": { "tool": {
"brush": "Brush", "brush": "Brush",
"eraser": "Eraser", "eraser": "Eraser",

View File

@ -3,7 +3,9 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import RgbColorPicker from 'common/components/RgbColorPicker'; import RgbColorPicker from 'common/components/RgbColorPicker';
import { rgbColorToString } from 'common/util/colorCodeTransformers'; import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { stopPropagation } from 'common/util/stopPropagation'; import { stopPropagation } from 'common/util/stopPropagation';
import { imFillChanged } from 'features/controlLayers/store/canvasV2Slice'; import { MaskFillStyle } from 'features/controlLayers/components/common/MaskFillStyle';
import { imFillColorChanged, imFillStyleChanged } from 'features/controlLayers/store/canvasV2Slice';
import type { FillStyle } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import type { RgbColor } from 'react-colorful'; import type { RgbColor } from 'react-colorful';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -12,9 +14,15 @@ export const InpaintMaskMaskFillColorPicker = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const fill = useAppSelector((s) => s.canvasV2.inpaintMask.fill); const fill = useAppSelector((s) => s.canvasV2.inpaintMask.fill);
const onChange = useCallback( const onChangeFillColor = useCallback(
(fill: RgbColor) => { (color: RgbColor) => {
dispatch(imFillChanged({ fill })); dispatch(imFillColorChanged({ color }));
},
[dispatch]
);
const onChangeFillStyle = useCallback(
(style: FillStyle) => {
dispatch(imFillStyleChanged({ style }));
}, },
[dispatch] [dispatch]
); );
@ -22,21 +30,23 @@ export const InpaintMaskMaskFillColorPicker = memo(() => {
<Popover isLazy> <Popover isLazy>
<PopoverTrigger> <PopoverTrigger>
<Flex <Flex
as="button" role="button"
aria-label={t('controlLayers.maskPreviewColor')} aria-label={t('controlLayers.maskPreviewColor')}
borderRadius="full" borderRadius="full"
borderWidth={1} borderWidth={1}
bg={rgbColorToString(fill)} bg={rgbColorToString(fill.color)}
w={8} w={8}
h={8} h={8}
cursor="pointer"
tabIndex={-1} tabIndex={-1}
onDoubleClick={stopPropagation} // double click expands the layer onDoubleClick={stopPropagation} // double click expands the layer
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<PopoverBody minH={64}> <PopoverBody minH={64}>
<RgbColorPicker color={fill} onChange={onChange} withNumberInput /> <Flex flexDir="column" gap={4}>
<RgbColorPicker color={fill.color} onChange={onChangeFillColor} withNumberInput />
<MaskFillStyle style={fill.style} onChange={onChangeFillStyle} />
</Flex>
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@ -3,9 +3,11 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import RgbColorPicker from 'common/components/RgbColorPicker'; import RgbColorPicker from 'common/components/RgbColorPicker';
import { rgbColorToString } from 'common/util/colorCodeTransformers'; import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { stopPropagation } from 'common/util/stopPropagation'; import { stopPropagation } from 'common/util/stopPropagation';
import { MaskFillStyle } from 'features/controlLayers/components/common/MaskFillStyle';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { rgFillChanged } from 'features/controlLayers/store/canvasV2Slice'; import { rgFillColorChanged, rgFillStyleChanged } from 'features/controlLayers/store/canvasV2Slice';
import { selectRegionalGuidanceEntityOrThrow } from 'features/controlLayers/store/regionsReducers'; import { selectRegionalGuidanceEntityOrThrow } from 'features/controlLayers/store/regionsReducers';
import type { FillStyle } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import type { RgbColor } from 'react-colorful'; import type { RgbColor } from 'react-colorful';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -15,9 +17,15 @@ export const RegionalGuidanceMaskFillColorPicker = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const fill = useAppSelector((s) => selectRegionalGuidanceEntityOrThrow(s.canvasV2, entityIdentifier.id).fill); const fill = useAppSelector((s) => selectRegionalGuidanceEntityOrThrow(s.canvasV2, entityIdentifier.id).fill);
const onChange = useCallback( const onChangeFillColor = useCallback(
(fill: RgbColor) => { (color: RgbColor) => {
dispatch(rgFillChanged({ id: entityIdentifier.id, fill })); dispatch(rgFillColorChanged({ id: entityIdentifier.id, color }));
},
[dispatch, entityIdentifier.id]
);
const onChangeFillStyle = useCallback(
(style: FillStyle) => {
dispatch(rgFillStyleChanged({ id: entityIdentifier.id, style }));
}, },
[dispatch, entityIdentifier.id] [dispatch, entityIdentifier.id]
); );
@ -25,21 +33,23 @@ export const RegionalGuidanceMaskFillColorPicker = memo(() => {
<Popover isLazy> <Popover isLazy>
<PopoverTrigger> <PopoverTrigger>
<Flex <Flex
as="button" role="button"
aria-label={t('controlLayers.maskPreviewColor')} aria-label={t('controlLayers.maskPreviewColor')}
borderRadius="full" borderRadius="full"
borderWidth={1} borderWidth={1}
bg={rgbColorToString(fill)} bg={rgbColorToString(fill.color)}
w={8} w={8}
h={8} h={8}
cursor="pointer"
tabIndex={-1} tabIndex={-1}
onDoubleClick={stopPropagation} // double click expands the layer onDoubleClick={stopPropagation} // double click expands the layer
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<PopoverBody minH={64}> <PopoverBody minH={64}>
<RgbColorPicker color={fill} onChange={onChange} withNumberInput /> <Flex flexDir="column" gap={4}>
<RgbColorPicker color={fill.color} onChange={onChangeFillColor} withNumberInput />
<MaskFillStyle style={fill.style} onChange={onChangeFillStyle} />
</Flex>
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@ -0,0 +1,64 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import type { FillStyle } from 'features/controlLayers/store/types';
import { isFillStyle } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
style: FillStyle;
onChange: (style: FillStyle) => void;
};
export const MaskFillStyle = memo(({ style, onChange }: Props) => {
const { t } = useTranslation();
const _onChange = useCallback<ComboboxOnChange>(
(v) => {
if (!isFillStyle(v?.value)) {
return;
}
onChange(v.value);
},
[onChange]
);
const options = useMemo<ComboboxOption[]>(() => {
return [
{
value: 'solid',
label: t('controlLayers.fill.solid'),
},
{
value: 'diagonal',
label: t('controlLayers.fill.diagonal'),
},
{
value: 'crosshatch',
label: t('controlLayers.fill.crosshatch'),
},
{
value: 'grid',
label: t('controlLayers.fill.grid'),
},
{
value: 'horizontal',
label: t('controlLayers.fill.horizontal'),
},
{
value: 'vertical',
label: t('controlLayers.fill.vertical'),
},
];
}, [t]);
const value = useMemo(() => options.find((o) => o.value === style), [options, style]);
return (
<FormControl>
<FormLabel m={0}>{t('controlLayers.fill.fillStyle')}</FormLabel>
<Combobox value={value} options={options} onChange={_onChange} isSearchable={false} />
</FormControl>
);
});
MaskFillStyle.displayName = 'MaskFillStyle';

View File

@ -8,24 +8,35 @@ import type { CanvasLayerAdapter } from 'features/controlLayers/konva/CanvasLaye
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { CanvasMaskAdapter } from 'features/controlLayers/konva/CanvasMaskAdapter'; import type { CanvasMaskAdapter } from 'features/controlLayers/konva/CanvasMaskAdapter';
import { CanvasRectRenderer } from 'features/controlLayers/konva/CanvasRect'; import { CanvasRectRenderer } from 'features/controlLayers/konva/CanvasRect';
import { getPatternSVG } from 'features/controlLayers/konva/patterns/getPatternSVG';
import { getPrefixedId, konvaNodeToBlob, konvaNodeToImageData, previewBlob } from 'features/controlLayers/konva/util'; import { getPrefixedId, konvaNodeToBlob, konvaNodeToImageData, previewBlob } from 'features/controlLayers/konva/util';
import type { import type {
CanvasBrushLineState, CanvasBrushLineState,
CanvasEraserLineState, CanvasEraserLineState,
CanvasImageState, CanvasImageState,
CanvasRectState, CanvasRectState,
Fill,
ImageCache, ImageCache,
Rect, Rect,
RgbColor,
} from 'features/controlLayers/store/types'; } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import type { RectConfig } from 'konva/lib/shapes/Rect';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import type { Logger } from 'roarr'; import type { Logger } from 'roarr';
import { getImageDTO, uploadImage } from 'services/api/endpoints/images'; import { getImageDTO, uploadImage } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
function setFillPatternImage(shape: Konva.Shape, ...args: Parameters<typeof getPatternSVG>): HTMLImageElement {
const imageElement = new Image();
imageElement.onload = () => {
shape.fillPatternImage(imageElement);
};
imageElement.src = getPatternSVG(...args);
return imageElement;
}
/** /**
* Union of all object renderers. * Union of all object renderers.
*/ */
@ -86,7 +97,11 @@ export class CanvasObjectRenderer {
* *
* The compositing rect is not added to the object group. * The compositing rect is not added to the object group.
*/ */
compositingRect: Konva.Rect | null; compositing: {
group: Konva.Group;
rect: Konva.Rect;
patternImage: HTMLImageElement;
} | null;
}; };
constructor(parent: CanvasLayerAdapter | CanvasMaskAdapter) { constructor(parent: CanvasLayerAdapter | CanvasMaskAdapter) {
@ -99,20 +114,26 @@ export class CanvasObjectRenderer {
this.konva = { this.konva = {
objectGroup: new Konva.Group({ name: `${this.type}:object_group`, listening: false }), objectGroup: new Konva.Group({ name: `${this.type}:object_group`, listening: false }),
compositingRect: null, compositing: null,
}; };
this.parent.konva.layer.add(this.konva.objectGroup); this.parent.konva.layer.add(this.konva.objectGroup);
if (this.parent.state.type === 'inpaint_mask' || this.parent.state.type === 'regional_guidance') { if (this.parent.state.type === 'inpaint_mask' || this.parent.state.type === 'regional_guidance') {
this.konva.compositingRect = new Konva.Rect({ const rect = new Konva.Rect({
name: `${this.type}:compositing_rect`, name: `${this.type}:compositing_rect`,
globalCompositeOperation: 'source-in', globalCompositeOperation: 'source-in',
listening: false, listening: false,
strokeEnabled: false, strokeEnabled: false,
perfectDrawEnabled: false, perfectDrawEnabled: false,
}); });
this.parent.konva.layer.add(this.konva.compositingRect); this.konva.compositing = {
group: new Konva.Group({ name: `${this.type}:compositing_group`, listening: false }),
rect,
patternImage: new Image(), // we will set the src on this on the first render
};
this.konva.compositing.group.add(this.konva.compositing.rect);
this.parent.konva.layer.add(this.konva.compositing.group);
} }
this.subscriptions.add( this.subscriptions.add(
@ -126,14 +147,9 @@ export class CanvasObjectRenderer {
// The compositing rect must cover the whole stage at all times. When the stage is scaled, moved or resized, we // The compositing rect must cover the whole stage at all times. When the stage is scaled, moved or resized, we
// need to update the compositing rect to match the stage. // need to update the compositing rect to match the stage.
this.subscriptions.add( this.subscriptions.add(
this.manager.stateApi.$stageAttrs.listen(({ x, y, width, height, scale }) => { this.manager.stateApi.$stageAttrs.listen(() => {
if (this.konva.compositingRect) { if (this.konva.compositing && this.parent.type === 'mask_adapter') {
this.konva.compositingRect.setAttrs({ this.updateCompositingRect(this.parent.state.fill, this.manager.stateApi.getMaskOpacity());
x: -x / scale,
y: -y / scale,
width: width / scale,
height: height / scale,
});
} }
}) })
); );
@ -167,20 +183,31 @@ export class CanvasObjectRenderer {
return didRender; return didRender;
}; };
updateCompositingRect = (fill: RgbColor, opacity: number) => { updateCompositingRect = (fill: Fill, opacity: number) => {
this.log.trace('Updating compositing rect'); this.log.trace('Updating compositing rect');
assert(this.konva.compositingRect, 'Missing compositing rect'); assert(this.konva.compositing, 'Missing compositing rect');
const rgbColor = rgbColorToString(fill);
const { x, y, width, height, scale } = this.manager.stateApi.$stageAttrs.get(); const { x, y, width, height, scale } = this.manager.stateApi.$stageAttrs.get();
this.konva.compositingRect.setAttrs({ console.log('stageAttrs', this.manager.stateApi.$stageAttrs.get());
fill: rgbColor, const attrs: RectConfig = {
opacity, opacity,
x: -x / scale, x: -x / scale,
y: -y / scale, y: -y / scale,
width: width / scale, width: width / scale,
height: height / scale, height: height / scale,
}); };
if (fill.style === 'solid') {
attrs.fill = rgbColorToString(fill.color);
attrs.fillPriority = 'color';
this.konva.compositing.rect.setAttrs(attrs);
} else {
attrs.fillPatternScaleX = 1 / scale;
attrs.fillPatternScaleY = 1 / scale;
attrs.fillPriority = 'pattern';
this.konva.compositing.rect.setAttrs(attrs);
setFillPatternImage(this.konva.compositing.rect, fill.style, fill.color);
}
}; };
/** /**

View File

@ -248,7 +248,7 @@ export class CanvasStateApi {
if (selectedEntity) { if (selectedEntity) {
// The brush should use the mask opacity for these entity types // The brush should use the mask opacity for these entity types
if (selectedEntity.state.type === 'regional_guidance' || selectedEntity.state.type === 'inpaint_mask') { if (selectedEntity.state.type === 'regional_guidance' || selectedEntity.state.type === 'inpaint_mask') {
currentFill = { ...selectedEntity.state.fill, a: this.getSettings().maskOpacity }; currentFill = { ...selectedEntity.state.fill.color, a: this.getSettings().maskOpacity };
} }
} }
return currentFill; return currentFill;

View File

@ -0,0 +1,27 @@
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import type { FillStyle, RgbColor } from 'features/controlLayers/store/types';
import crosshatch from './pattern-crosshatch.svg?raw';
import diagonal from './pattern-diagonal.svg?raw';
import grid from './pattern-grid.svg?raw';
import horizontal from './pattern-horizontal.svg?raw';
import vertical from './pattern-vertical.svg?raw';
export function getPatternSVG(pattern: Exclude<FillStyle, 'solid'>, color: RgbColor) {
let content: string = 'data:image/svg+xml;utf8,';
if (pattern === 'crosshatch') {
content += crosshatch;
} else if (pattern === 'diagonal') {
content += diagonal;
} else if (pattern === 'horizontal') {
content += horizontal;
} else if (pattern === 'vertical') {
content += vertical;
} else if (pattern === 'grid') {
content += grid;
}
content = content.replaceAll('stroke:black', `stroke:${rgbColorToString(color)}`);
return content;
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="6px" height="6px" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<path d="M0,10L9,1" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M3,13L14,2" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M-1,5L8,-4" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M1,-2L13,10" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M-1,2L10,13" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M5,-4L14,5" style="fill:none;stroke:black;stroke-width:1px;"/>
</svg>

After

Width:  |  Height:  |  Size: 920 B

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="9px" height="9px" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<path d="M9.9,7.2L7.2,9.9" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M9.9,2.7L2.7,9.9" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M9,-0.9L-0.9,9" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M4.5,-0.9L-0.9,4.5" style="fill:none;stroke:black;stroke-width:1px;"/>
</svg>

After

Width:  |  Height:  |  Size: 791 B

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="12px" height="12px" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<path d="M0.5,-1L0.5,12" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M4.5,-1L4.5,12" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M8.5,-1L8.5,12" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M-1,0.5L12,0.5" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M-1,4.5L12,4.5" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M-1,8.5L12,8.5" style="fill:none;stroke:black;stroke-width:1px;"/>
</svg>

After

Width:  |  Height:  |  Size: 945 B

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="9px" height="9px" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<path d="M10,0.5L-1,0.5" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M10,3.5L-1,3.5" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M10,6.5L-1,6.5" style="fill:none;stroke:black;stroke-width:1px;"/>
</svg>

After

Width:  |  Height:  |  Size: 703 B

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="9px" height="9px" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<path d="M0.5,-1L0.5,10" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M3.5,-1L3.5,10" style="fill:none;stroke:black;stroke-width:1px;"/>
<path d="M6.5,-1L6.5,10" style="fill:none;stroke:black;stroke-width:1px;"/>
</svg>

After

Width:  |  Height:  |  Size: 703 B

View File

@ -53,7 +53,10 @@ const initialState: CanvasV2State = {
inpaintMask: { inpaintMask: {
id: 'inpaint_mask', id: 'inpaint_mask',
type: 'inpaint_mask', type: 'inpaint_mask',
fill: RGBA_RED, fill: {
style: 'diagonal',
color: RGBA_RED,
},
rasterizationCache: [], rasterizationCache: [],
isEnabled: true, isEnabled: true,
objects: [], objects: [],
@ -531,7 +534,8 @@ export const {
rgAllDeleted, rgAllDeleted,
rgPositivePromptChanged, rgPositivePromptChanged,
rgNegativePromptChanged, rgNegativePromptChanged,
rgFillChanged, rgFillColorChanged,
rgFillStyleChanged,
rgAutoNegativeChanged, rgAutoNegativeChanged,
rgIPAdapterAdded, rgIPAdapterAdded,
rgIPAdapterDeleted, rgIPAdapterDeleted,
@ -587,7 +591,8 @@ export const {
loraAllDeleted, loraAllDeleted,
// Inpaint mask // Inpaint mask
imRecalled, imRecalled,
imFillChanged, imFillColorChanged,
imFillStyleChanged,
// Staging // Staging
sessionStartedStaging, sessionStartedStaging,
sessionImageStaged, sessionImageStaged,

View File

@ -1,7 +1,6 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit'; import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import type { CanvasInpaintMaskState, CanvasV2State } from 'features/controlLayers/store/types'; import type { CanvasInpaintMaskState, CanvasV2State, FillStyle } from 'features/controlLayers/store/types';
import type { RgbColor } from 'react-colorful';
import type { RgbColor } from './types';
export const inpaintMaskReducers = { export const inpaintMaskReducers = {
imRecalled: (state, action: PayloadAction<{ data: CanvasInpaintMaskState }>) => { imRecalled: (state, action: PayloadAction<{ data: CanvasInpaintMaskState }>) => {
@ -9,8 +8,12 @@ export const inpaintMaskReducers = {
state.inpaintMask = data; state.inpaintMask = data;
state.selectedEntityIdentifier = { type: 'inpaint_mask', id: data.id }; state.selectedEntityIdentifier = { type: 'inpaint_mask', id: data.id };
}, },
imFillChanged: (state, action: PayloadAction<{ fill: RgbColor }>) => { imFillColorChanged: (state, action: PayloadAction<{ color: RgbColor }>) => {
const { fill } = action.payload; const { color } = action.payload;
state.inpaintMask.fill = fill; state.inpaintMask.fill.color = color;
},
imFillStyleChanged: (state, action: PayloadAction<{ style: FillStyle }>) => {
const { style } = action.payload;
state.inpaintMask.fill.style = style;
}, },
} satisfies SliceCaseReducers<CanvasV2State>; } satisfies SliceCaseReducers<CanvasV2State>;

View File

@ -3,6 +3,7 @@ import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { import type {
CanvasV2State, CanvasV2State,
CLIPVisionModelV2, CLIPVisionModelV2,
FillStyle,
IPMethodV2, IPMethodV2,
RegionalGuidanceIPAdapterConfig, RegionalGuidanceIPAdapterConfig,
} from 'features/controlLayers/store/types'; } from 'features/controlLayers/store/types';
@ -42,7 +43,7 @@ const DEFAULT_MASK_COLORS: RgbColor[] = [
]; ];
const getRGMaskFill = (state: CanvasV2State): RgbColor => { const getRGMaskFill = (state: CanvasV2State): RgbColor => {
const lastFill = state.regions.entities.slice(-1)[0]?.fill; const lastFill = state.regions.entities.slice(-1)[0]?.fill.color;
let i = DEFAULT_MASK_COLORS.findIndex((c) => isEqual(c, lastFill)); let i = DEFAULT_MASK_COLORS.findIndex((c) => isEqual(c, lastFill));
if (i === -1) { if (i === -1) {
i = 0; i = 0;
@ -63,7 +64,10 @@ export const regionsReducers = {
type: 'regional_guidance', type: 'regional_guidance',
isEnabled: true, isEnabled: true,
objects: [], objects: [],
fill: getRGMaskFill(state), fill: {
style: 'solid',
color: getRGMaskFill(state),
},
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
autoNegative: 'invert', autoNegative: 'invert',
positivePrompt: '', positivePrompt: '',
@ -100,14 +104,23 @@ export const regionsReducers = {
} }
entity.negativePrompt = prompt; entity.negativePrompt = prompt;
}, },
rgFillChanged: (state, action: PayloadAction<{ id: string; fill: RgbColor }>) => { rgFillColorChanged: (state, action: PayloadAction<{ id: string; color: RgbColor }>) => {
const { id, fill } = action.payload; const { id, color } = action.payload;
const entity = selectRegionalGuidanceEntity(state, id); const entity = selectRegionalGuidanceEntity(state, id);
if (!entity) { if (!entity) {
return; return;
} }
entity.fill = fill; entity.fill.color = color;
}, },
rgFillStyleChanged: (state, action: PayloadAction<{ id: string; style: FillStyle }>) => {
const { id, style } = action.payload;
const entity = selectRegionalGuidanceEntity(state, id);
if (!entity) {
return;
}
entity.fill.style = style;
},
rgAutoNegativeChanged: (state, action: PayloadAction<{ id: string; autoNegative: ParameterAutoNegative }>) => { rgAutoNegativeChanged: (state, action: PayloadAction<{ id: string; autoNegative: ParameterAutoNegative }>) => {
const { id, autoNegative } = action.payload; const { id, autoNegative } = action.payload;
const rg = selectRegionalGuidanceEntity(state, id); const rg = selectRegionalGuidanceEntity(state, id);

View File

@ -641,6 +641,12 @@ const zMaskObject = z
}) })
.pipe(z.discriminatedUnion('type', [zCanvasBrushLineState, zCanvasEraserLineState, zCanvasRectState])); .pipe(z.discriminatedUnion('type', [zCanvasBrushLineState, zCanvasEraserLineState, zCanvasRectState]));
const zFillStyle = z.enum(['solid', 'grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical']);
export type FillStyle = z.infer<typeof zFillStyle>;
export const isFillStyle = (v: unknown): v is FillStyle => zFillStyle.safeParse(v).success;
const zFill = z.object({ style: zFillStyle, color: zRgbColor });
export type Fill = z.infer<typeof zFill>;
const zImageCache = z.object({ const zImageCache = z.object({
imageName: z.string(), imageName: z.string(),
rect: zRect, rect: zRect,
@ -665,7 +671,7 @@ export const zCanvasRegionalGuidanceState = z.object({
isEnabled: z.boolean(), isEnabled: z.boolean(),
position: zCoordinate, position: zCoordinate,
objects: z.array(zCanvasObjectState), objects: z.array(zCanvasObjectState),
fill: zRgbColor, fill: zFill,
positivePrompt: zParameterPositivePrompt.nullable(), positivePrompt: zParameterPositivePrompt.nullable(),
negativePrompt: zParameterNegativePrompt.nullable(), negativePrompt: zParameterNegativePrompt.nullable(),
ipAdapters: z.array(zRegionalGuidanceIPAdapterConfig), ipAdapters: z.array(zRegionalGuidanceIPAdapterConfig),
@ -674,21 +680,12 @@ export const zCanvasRegionalGuidanceState = z.object({
}); });
export type CanvasRegionalGuidanceState = z.infer<typeof zCanvasRegionalGuidanceState>; export type CanvasRegionalGuidanceState = z.infer<typeof zCanvasRegionalGuidanceState>;
const zColorFill = z.object({
type: z.literal('color_fill'),
color: zRgbaColor,
});
const zImageFill = z.object({
type: z.literal('image_fill'),
src: z.string(),
});
const zFill = z.discriminatedUnion('type', [zColorFill, zImageFill]);
const zCanvasInpaintMaskState = z.object({ const zCanvasInpaintMaskState = z.object({
id: z.literal('inpaint_mask'), id: z.literal('inpaint_mask'),
type: z.literal('inpaint_mask'), type: z.literal('inpaint_mask'),
isEnabled: z.boolean(), isEnabled: z.boolean(),
position: zCoordinate, position: zCoordinate,
fill: zRgbColor, fill: zFill,
objects: z.array(zCanvasObjectState), objects: z.array(zCanvasObjectState),
rasterizationCache: z.array(zImageCache), rasterizationCache: z.array(zImageCache),
}); });