feat(ui): add raster layer rendering and interaction (WIP)

This commit is contained in:
psychedelicious 2024-06-05 21:24:26 +10:00
parent f663215f25
commit d0c40a8b5b
10 changed files with 529 additions and 218 deletions

View File

@ -182,7 +182,7 @@ const createSelector = (templates: Templates) =>
if (l.type === 'regional_guidance_layer') { if (l.type === 'regional_guidance_layer') {
// Must have a region // Must have a region
if (l.maskObjects.length === 0) { if (l.objects.length === 0) {
problems.push(i18n.t('parameters.invoke.layer.rgNoRegion')); problems.push(i18n.t('parameters.invoke.layer.rgNoRegion'));
} }
// Must have at least 1 prompt or IP Adapter // Must have at least 1 prompt or IP Adapter

View File

@ -0,0 +1,48 @@
import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIColorPicker from 'common/components/IAIColorPicker';
import { rgbaColorToString } from 'features/canvas/util/colorToString';
import { brushColorChanged } from 'features/controlLayers/store/controlLayersSlice';
import type { RgbaColor } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const BrushColorPicker = memo(() => {
const { t } = useTranslation();
const brushColor = useAppSelector((s) => s.controlLayers.present.brushColor);
const dispatch = useAppDispatch();
const onChange = useCallback(
(color: RgbaColor) => {
dispatch(brushColorChanged(color));
},
[dispatch]
);
return (
<Popover isLazy>
<PopoverTrigger>
<span>
<Tooltip label={t('controlLayers.brushColor')}>
<Flex
as="button"
aria-label={t('controlLayers.brushColor')}
borderRadius="full"
borderWidth={1}
bg={rgbaColorToString(brushColor)}
w={8}
h={8}
cursor="pointer"
tabIndex={-1}
/>
</Tooltip>
</span>
</PopoverTrigger>
<PopoverContent>
<PopoverBody minH={64}>
<IAIColorPicker color={brushColor} onChange={onChange} withNumberInput />
</PopoverBody>
</PopoverContent>
</Popover>
);
});
BrushColorPicker.displayName = 'BrushColorPicker';

View File

@ -1,5 +1,6 @@
/* eslint-disable i18next/no-literal-string */ /* eslint-disable i18next/no-literal-string */
import { Flex } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library';
import { BrushColorPicker } from 'features/controlLayers/components/BrushColorPicker';
import { BrushSize } from 'features/controlLayers/components/BrushSize'; import { BrushSize } from 'features/controlLayers/components/BrushSize';
import ControlLayersSettingsPopover from 'features/controlLayers/components/ControlLayersSettingsPopover'; import ControlLayersSettingsPopover from 'features/controlLayers/components/ControlLayersSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/ToolChooser'; import { ToolChooser } from 'features/controlLayers/components/ToolChooser';
@ -18,6 +19,7 @@ export const ControlLayersToolbar = memo(() => {
</Flex> </Flex>
<Flex flex={1} gap={2} justifyContent="center"> <Flex flex={1} gap={2} justifyContent="center">
<BrushSize /> <BrushSize />
<BrushColorPicker />
<ToolChooser /> <ToolChooser />
<UndoRedoButtonGroup /> <UndoRedoButtonGroup />
<ControlLayersSettingsPopover /> <ControlLayersSettingsPopover />

View File

@ -8,26 +8,32 @@ import { BRUSH_SPACING_PCT, MAX_BRUSH_SPACING_PX, MIN_BRUSH_SPACING_PX } from 'f
import { setStageEventHandlers } from 'features/controlLayers/konva/events'; import { setStageEventHandlers } from 'features/controlLayers/konva/events';
import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/konva/renderers'; import { debouncedRenderers, renderers as normalRenderers } from 'features/controlLayers/konva/renderers';
import { import {
$brushColor,
$brushSize, $brushSize,
$brushSpacingPx, $brushSpacingPx,
$isDrawing, $isDrawing,
$lastAddedPoint, $lastAddedPoint,
$lastCursorPos, $lastCursorPos,
$lastMouseDownPos, $lastMouseDownPos,
$selectedLayerId, $selectedLayer,
$selectedLayerType,
$shouldInvertBrushSizeScrollDirection, $shouldInvertBrushSizeScrollDirection,
$tool, $tool,
brushLineAdded,
brushSizeChanged, brushSizeChanged,
eraserLineAdded,
isRegionalGuidanceLayer, isRegionalGuidanceLayer,
layerBboxChanged, layerBboxChanged,
layerTranslated, layerTranslated,
rgLayerLineAdded, linePointsAdded,
rgLayerPointsAdded, rectAdded,
rgLayerRectAdded,
selectControlLayersSlice, selectControlLayersSlice,
} from 'features/controlLayers/store/controlLayersSlice'; } from 'features/controlLayers/store/controlLayersSlice';
import type { AddLineArg, AddPointToLineArg, AddRectArg } from 'features/controlLayers/store/types'; import type {
AddBrushLineArg,
AddEraserLineArg,
AddPointToLineArg,
AddRectShapeArg,
} from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
@ -41,16 +47,20 @@ Konva.showWarnings = false;
const log = logger('controlLayers'); const log = logger('controlLayers');
const selectSelectedLayerColor = createMemoizedSelector(selectControlLayersSlice, (controlLayers) => { const selectBrushColor = createMemoizedSelector(selectControlLayersSlice, (controlLayers) => {
const layer = controlLayers.present.layers const layer = controlLayers.present.layers
.filter(isRegionalGuidanceLayer) .filter(isRegionalGuidanceLayer)
.find((l) => l.id === controlLayers.present.selectedLayerId); .find((l) => l.id === controlLayers.present.selectedLayerId);
return layer?.previewColor ?? null;
if (layer) {
return { ...layer.previewColor, a: controlLayers.present.globalMaskLayerOpacity };
}
return controlLayers.present.brushColor;
}); });
const selectSelectedLayerType = createSelector(selectControlLayersSlice, (controlLayers) => { const selectSelectedLayer = createSelector(selectControlLayersSlice, (controlLayers) => {
const selectedLayer = controlLayers.present.layers.find((l) => l.id === controlLayers.present.selectedLayerId); return controlLayers.present.layers.find((l) => l.id === controlLayers.present.selectedLayerId) ?? null;
return selectedLayer?.type ?? null;
}); });
const useStageRenderer = ( const useStageRenderer = (
@ -64,8 +74,8 @@ const useStageRenderer = (
const tool = useStore($tool); const tool = useStore($tool);
const lastCursorPos = useStore($lastCursorPos); const lastCursorPos = useStore($lastCursorPos);
const lastMouseDownPos = useStore($lastMouseDownPos); const lastMouseDownPos = useStore($lastMouseDownPos);
const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor); const brushColor = useAppSelector(selectBrushColor);
const selectedLayerType = useAppSelector(selectSelectedLayerType); const selectedLayer = useAppSelector(selectSelectedLayer);
const layerIds = useMemo(() => state.layers.map((l) => l.id), [state.layers]); const layerIds = useMemo(() => state.layers.map((l) => l.id), [state.layers]);
const layerCount = useMemo(() => state.layers.length, [state.layers]); const layerCount = useMemo(() => state.layers.length, [state.layers]);
const renderers = useMemo(() => (asPreview ? debouncedRenderers : normalRenderers), [asPreview]); const renderers = useMemo(() => (asPreview ? debouncedRenderers : normalRenderers), [asPreview]);
@ -77,18 +87,19 @@ const useStageRenderer = (
); );
useLayoutEffect(() => { useLayoutEffect(() => {
$brushColor.set(brushColor);
$brushSize.set(state.brushSize); $brushSize.set(state.brushSize);
$brushSpacingPx.set(brushSpacingPx); $brushSpacingPx.set(brushSpacingPx);
$selectedLayerId.set(state.selectedLayerId); $selectedLayer.set(selectedLayer);
$selectedLayerType.set(selectedLayerType);
$shouldInvertBrushSizeScrollDirection.set(shouldInvertBrushSizeScrollDirection); $shouldInvertBrushSizeScrollDirection.set(shouldInvertBrushSizeScrollDirection);
}, [ }, [
brushSpacingPx, brushSpacingPx,
selectedLayerIdColor, brushColor,
selectedLayerType, selectedLayer,
shouldInvertBrushSizeScrollDirection, shouldInvertBrushSizeScrollDirection,
state.brushSize, state.brushSize,
state.selectedLayerId, state.selectedLayerId,
state.brushColor,
]); ]);
const onLayerPosChanged = useCallback( const onLayerPosChanged = useCallback(
@ -105,21 +116,27 @@ const useStageRenderer = (
[dispatch] [dispatch]
); );
const onRGLayerLineAdded = useCallback( const onBrushLineAdded = useCallback(
(arg: AddLineArg) => { (arg: AddBrushLineArg) => {
dispatch(rgLayerLineAdded(arg)); dispatch(brushLineAdded(arg));
}, },
[dispatch] [dispatch]
); );
const onRGLayerPointAddedToLine = useCallback( const onEraserLineAdded = useCallback(
(arg: AddEraserLineArg) => {
dispatch(eraserLineAdded(arg));
},
[dispatch]
);
const onPointAddedToLine = useCallback(
(arg: AddPointToLineArg) => { (arg: AddPointToLineArg) => {
dispatch(rgLayerPointsAdded(arg)); dispatch(linePointsAdded(arg));
}, },
[dispatch] [dispatch]
); );
const onRGLayerRectAdded = useCallback( const onRectShapeAdded = useCallback(
(arg: AddRectArg) => { (arg: AddRectShapeArg) => {
dispatch(rgLayerRectAdded(arg)); dispatch(rectAdded(arg));
}, },
[dispatch] [dispatch]
); );
@ -155,21 +172,22 @@ const useStageRenderer = (
$lastCursorPos, $lastCursorPos,
$lastAddedPoint, $lastAddedPoint,
$brushSize, $brushSize,
$brushColor,
$brushSpacingPx, $brushSpacingPx,
$selectedLayerId, $selectedLayer,
$selectedLayerType,
$shouldInvertBrushSizeScrollDirection, $shouldInvertBrushSizeScrollDirection,
onRGLayerLineAdded,
onRGLayerPointAddedToLine,
onRGLayerRectAdded,
onBrushSizeChanged, onBrushSizeChanged,
onBrushLineAdded,
onEraserLineAdded,
onPointAddedToLine,
onRectShapeAdded,
}); });
return () => { return () => {
log.trace('Removing stage listeners'); log.trace('Removing stage listeners');
cleanup(); cleanup();
}; };
}, [asPreview, onBrushSizeChanged, onRGLayerLineAdded, onRGLayerPointAddedToLine, onRGLayerRectAdded, stage]); }, [asPreview, onBrushLineAdded, onBrushSizeChanged, onEraserLineAdded, onPointAddedToLine, onRectShapeAdded, stage]);
useLayoutEffect(() => { useLayoutEffect(() => {
log.trace('Updating stage dimensions'); log.trace('Updating stage dimensions');
@ -205,8 +223,8 @@ const useStageRenderer = (
renderers.renderToolPreview( renderers.renderToolPreview(
stage, stage,
tool, tool,
selectedLayerIdColor, brushColor,
selectedLayerType, selectedLayer?.type ?? null,
state.globalMaskLayerOpacity, state.globalMaskLayerOpacity,
lastCursorPos, lastCursorPos,
lastMouseDownPos, lastMouseDownPos,
@ -216,8 +234,8 @@ const useStageRenderer = (
asPreview, asPreview,
stage, stage,
tool, tool,
selectedLayerIdColor, brushColor,
selectedLayerType, selectedLayer,
state.globalMaskLayerOpacity, state.globalMaskLayerOpacity,
lastCursorPos, lastCursorPos,
lastMouseDownPos, lastMouseDownPos,

View File

@ -15,7 +15,7 @@ import { PiArrowsOutCardinalBold, PiEraserBold, PiPaintBrushBold, PiRectangleBol
const selectIsDisabled = createSelector(selectControlLayersSlice, (controlLayers) => { const selectIsDisabled = createSelector(selectControlLayersSlice, (controlLayers) => {
const selectedLayer = controlLayers.present.layers.find((l) => l.id === controlLayers.present.selectedLayerId); const selectedLayer = controlLayers.present.layers.find((l) => l.id === controlLayers.present.selectedLayerId);
return selectedLayer?.type !== 'regional_guidance_layer'; return selectedLayer?.type !== 'regional_guidance_layer' && selectedLayer?.type !== 'raster_layer';
}); });
export const ToolChooser: React.FC = () => { export const ToolChooser: React.FC = () => {

View File

@ -5,10 +5,19 @@ import {
getScaledFlooredCursorPosition, getScaledFlooredCursorPosition,
snapPosToStage, snapPosToStage,
} from 'features/controlLayers/konva/util'; } from 'features/controlLayers/konva/util';
import type { AddLineArg, AddPointToLineArg, AddRectArg, Layer, Tool } from 'features/controlLayers/store/types'; import {
type AddBrushLineArg,
type AddEraserLineArg,
type AddPointToLineArg,
type AddRectShapeArg,
DEFAULT_RGBA_COLOR,
type Layer,
type Tool,
} from 'features/controlLayers/store/types';
import type Konva from 'konva'; import type Konva from 'konva';
import type { Vector2d } from 'konva/lib/types'; import type { Vector2d } from 'konva/lib/types';
import type { WritableAtom } from 'nanostores'; import type { WritableAtom } from 'nanostores';
import type { RgbaColor } from 'react-colorful';
import { TOOL_PREVIEW_LAYER_ID } from './naming'; import { TOOL_PREVIEW_LAYER_ID } from './naming';
@ -19,14 +28,15 @@ type SetStageEventHandlersArg = {
$lastMouseDownPos: WritableAtom<Vector2d | null>; $lastMouseDownPos: WritableAtom<Vector2d | null>;
$lastCursorPos: WritableAtom<Vector2d | null>; $lastCursorPos: WritableAtom<Vector2d | null>;
$lastAddedPoint: WritableAtom<Vector2d | null>; $lastAddedPoint: WritableAtom<Vector2d | null>;
$brushColor: WritableAtom<RgbaColor>;
$brushSize: WritableAtom<number>; $brushSize: WritableAtom<number>;
$brushSpacingPx: WritableAtom<number>; $brushSpacingPx: WritableAtom<number>;
$selectedLayerId: WritableAtom<string | null>; $selectedLayer: WritableAtom<Layer | null>;
$selectedLayerType: WritableAtom<Layer['type'] | null>;
$shouldInvertBrushSizeScrollDirection: WritableAtom<boolean>; $shouldInvertBrushSizeScrollDirection: WritableAtom<boolean>;
onRGLayerLineAdded: (arg: AddLineArg) => void; onBrushLineAdded: (arg: AddBrushLineArg) => void;
onRGLayerPointAddedToLine: (arg: AddPointToLineArg) => void; onEraserLineAdded: (arg: AddEraserLineArg) => void;
onRGLayerRectAdded: (arg: AddRectArg) => void; onPointAddedToLine: (arg: AddPointToLineArg) => void;
onRectShapeAdded: (arg: AddRectShapeArg) => void;
onBrushSizeChanged: (size: number) => void; onBrushSizeChanged: (size: number) => void;
}; };
@ -46,14 +56,15 @@ export const setStageEventHandlers = ({
$lastMouseDownPos, $lastMouseDownPos,
$lastCursorPos, $lastCursorPos,
$lastAddedPoint, $lastAddedPoint,
$brushColor,
$brushSize, $brushSize,
$brushSpacingPx, $brushSpacingPx,
$selectedLayerId, $selectedLayer,
$selectedLayerType,
$shouldInvertBrushSizeScrollDirection, $shouldInvertBrushSizeScrollDirection,
onRGLayerLineAdded, onBrushLineAdded,
onRGLayerPointAddedToLine, onEraserLineAdded,
onRGLayerRectAdded, onPointAddedToLine,
onRectShapeAdded,
onBrushSizeChanged, onBrushSizeChanged,
}: SetStageEventHandlersArg): (() => void) => { }: SetStageEventHandlersArg): (() => void) => {
stage.on('mouseenter', (e) => { stage.on('mouseenter', (e) => {
@ -72,16 +83,25 @@ export const setStageEventHandlers = ({
} }
const tool = $tool.get(); const tool = $tool.get();
const pos = syncCursorPos(stage, $lastCursorPos); const pos = syncCursorPos(stage, $lastCursorPos);
const selectedLayerId = $selectedLayerId.get(); const selectedLayer = $selectedLayer.get();
const selectedLayerType = $selectedLayerType.get(); if (!pos || !selectedLayer) {
if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') {
return; return;
} }
if (tool === 'brush' || tool === 'eraser') { if (selectedLayer.type !== 'regional_guidance_layer' && selectedLayer.type !== 'raster_layer') {
onRGLayerLineAdded({ return;
layerId: selectedLayerId, }
if (tool === 'brush') {
onBrushLineAdded({
layerId: selectedLayer.id,
points: [pos.x, pos.y, pos.x, pos.y],
color: selectedLayer.type === 'raster_layer' ? $brushColor.get() : DEFAULT_RGBA_COLOR,
});
$isDrawing.set(true);
$lastMouseDownPos.set(pos);
} else if (tool === 'eraser') {
onEraserLineAdded({
layerId: selectedLayer.id,
points: [pos.x, pos.y, pos.x, pos.y], points: [pos.x, pos.y, pos.x, pos.y],
tool,
}); });
$isDrawing.set(true); $isDrawing.set(true);
$lastMouseDownPos.set(pos); $lastMouseDownPos.set(pos);
@ -96,24 +116,27 @@ export const setStageEventHandlers = ({
return; return;
} }
const pos = $lastCursorPos.get(); const pos = $lastCursorPos.get();
const selectedLayerId = $selectedLayerId.get(); const selectedLayer = $selectedLayer.get();
const selectedLayerType = $selectedLayerType.get();
if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') { if (!pos || !selectedLayer) {
return;
}
if (selectedLayer.type !== 'regional_guidance_layer' && selectedLayer.type !== 'raster_layer') {
return; return;
} }
const lastPos = $lastMouseDownPos.get(); const lastPos = $lastMouseDownPos.get();
const tool = $tool.get(); const tool = $tool.get();
if (lastPos && selectedLayerId && tool === 'rect') { if (lastPos && selectedLayer.id && tool === 'rect') {
const snappedPos = snapPosToStage(pos, stage); const snappedPos = snapPosToStage(pos, stage);
onRGLayerRectAdded({ onRectShapeAdded({
layerId: selectedLayerId, layerId: selectedLayer.id,
rect: { rect: {
x: Math.min(snappedPos.x, lastPos.x), x: Math.min(snappedPos.x, lastPos.x),
y: Math.min(snappedPos.y, lastPos.y), y: Math.min(snappedPos.y, lastPos.y),
width: Math.abs(snappedPos.x - lastPos.x), width: Math.abs(snappedPos.x - lastPos.x),
height: Math.abs(snappedPos.y - lastPos.y), height: Math.abs(snappedPos.y - lastPos.y),
}, },
color: selectedLayer.type === 'raster_layer' ? $brushColor.get() : DEFAULT_RGBA_COLOR,
}); });
} }
$isDrawing.set(false); $isDrawing.set(false);
@ -127,12 +150,14 @@ export const setStageEventHandlers = ({
} }
const tool = $tool.get(); const tool = $tool.get();
const pos = syncCursorPos(stage, $lastCursorPos); const pos = syncCursorPos(stage, $lastCursorPos);
const selectedLayerId = $selectedLayerId.get(); const selectedLayer = $selectedLayer.get();
const selectedLayerType = $selectedLayerType.get();
stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(tool === 'brush' || tool === 'eraser'); stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(tool === 'brush' || tool === 'eraser');
if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') { if (!pos || !selectedLayer) {
return;
}
if (selectedLayer.type !== 'regional_guidance_layer' && selectedLayer.type !== 'raster_layer') {
return; return;
} }
if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) { if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
@ -146,10 +171,21 @@ export const setStageEventHandlers = ({
} }
} }
$lastAddedPoint.set({ x: pos.x, y: pos.y }); $lastAddedPoint.set({ x: pos.x, y: pos.y });
onRGLayerPointAddedToLine({ layerId: selectedLayerId, point: [pos.x, pos.y] }); onPointAddedToLine({ layerId: selectedLayer.id, point: [pos.x, pos.y] });
} else { } else {
if (tool === 'brush') {
// Start a new line // Start a new line
onRGLayerLineAdded({ layerId: selectedLayerId, points: [pos.x, pos.y, pos.x, pos.y], tool }); onBrushLineAdded({
layerId: selectedLayer.id,
points: [pos.x, pos.y, pos.x, pos.y],
color: selectedLayer.type === 'raster_layer' ? $brushColor.get() : DEFAULT_RGBA_COLOR,
});
} else if (tool === 'eraser') {
onEraserLineAdded({
layerId: selectedLayer.id,
points: [pos.x, pos.y, pos.x, pos.y],
});
}
} }
$isDrawing.set(true); $isDrawing.set(true);
} }
@ -164,28 +200,36 @@ export const setStageEventHandlers = ({
$isDrawing.set(false); $isDrawing.set(false);
$lastCursorPos.set(null); $lastCursorPos.set(null);
$lastMouseDownPos.set(null); $lastMouseDownPos.set(null);
const selectedLayerId = $selectedLayerId.get(); const selectedLayer = $selectedLayer.get();
const selectedLayerType = $selectedLayerType.get();
const tool = $tool.get(); const tool = $tool.get();
stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false); stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
if (!pos || !selectedLayerId || selectedLayerType !== 'regional_guidance_layer') { if (!pos || !selectedLayer) {
return;
}
if (selectedLayer.type !== 'regional_guidance_layer' && selectedLayer.type !== 'raster_layer') {
return; return;
} }
if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) { if (getIsFocused(stage) && getIsMouseDown(e) && (tool === 'brush' || tool === 'eraser')) {
onRGLayerPointAddedToLine({ layerId: selectedLayerId, point: [pos.x, pos.y] }); onPointAddedToLine({ layerId: selectedLayer.id, point: [pos.x, pos.y] });
} }
}); });
stage.on('wheel', (e) => { stage.on('wheel', (e) => {
e.evt.preventDefault(); e.evt.preventDefault();
const selectedLayerType = $selectedLayerType.get();
const tool = $tool.get(); const tool = $tool.get();
if (selectedLayerType !== 'regional_guidance_layer' || (tool !== 'brush' && tool !== 'eraser')) { const selectedLayer = $selectedLayer.get();
if (tool !== 'brush' && tool !== 'eraser') {
return;
}
if (!selectedLayer) {
return;
}
if (selectedLayer.type !== 'regional_guidance_layer' && selectedLayer.type !== 'raster_layer') {
return; return;
} }
// Invert the delta if the property is set to true // Invert the delta if the property is set to true
let delta = e.evt.deltaY; let delta = e.evt.deltaY;
if ($shouldInvertBrushSizeScrollDirection.get()) { if ($shouldInvertBrushSizeScrollDirection.get()) {

View File

@ -26,13 +26,17 @@ export const INITIAL_IMAGE_LAYER_IMAGE_NAME = 'initial_image_layer.image';
export const LAYER_BBOX_NAME = 'layer.bbox'; export const LAYER_BBOX_NAME = 'layer.bbox';
export const COMPOSITING_RECT_NAME = 'compositing-rect'; export const COMPOSITING_RECT_NAME = 'compositing-rect';
export const RASTER_LAYER_NAME = 'raster_layer'; export const RASTER_LAYER_NAME = 'raster_layer';
export const RASTER_LAYER_LINE_NAME = 'raster_layer.line';
export const RASTER_LAYER_OBJECT_GROUP_NAME = 'raster_layer.object_group';
export const RASTER_LAYER_RECT_NAME = 'raster_layer.rect';
// Getters for non-singleton layer and object IDs // Getters for non-singleton layer and object IDs
export const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`; export const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`;
export const getRasterLayerId = (layerId: string) => `${RASTER_LAYER_NAME}_${layerId}`; export const getRasterLayerId = (layerId: string) => `${RASTER_LAYER_NAME}_${layerId}`;
export const getRGLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; export const getBrushLineId = (layerId: string, lineId: string) => `${layerId}.brush_line_${lineId}`;
export const getRGLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`; export const getEraserLineId = (layerId: string, lineId: string) => `${layerId}.eraser_line_${lineId}`;
export const getRGLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`; export const getRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`;
export const getObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`;
export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`; export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
export const getCALayerId = (layerId: string) => `control_adapter_layer_${layerId}`; export const getCALayerId = (layerId: string) => `control_adapter_layer_${layerId}`;
export const getCALayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`; export const getCALayerImageId = (layerId: string, imageName: string) => `${layerId}.image_${imageName}`;

View File

@ -10,11 +10,13 @@ import {
getCALayerImageId, getCALayerImageId,
getIILayerImageId, getIILayerImageId,
getLayerBboxId, getLayerBboxId,
getRGLayerObjectGroupId, getObjectGroupId,
INITIAL_IMAGE_LAYER_IMAGE_NAME, INITIAL_IMAGE_LAYER_IMAGE_NAME,
INITIAL_IMAGE_LAYER_NAME, INITIAL_IMAGE_LAYER_NAME,
LAYER_BBOX_NAME, LAYER_BBOX_NAME,
NO_LAYERS_MESSAGE_LAYER_ID, NO_LAYERS_MESSAGE_LAYER_ID,
RASTER_LAYER_NAME,
RASTER_LAYER_OBJECT_GROUP_NAME,
RG_LAYER_LINE_NAME, RG_LAYER_LINE_NAME,
RG_LAYER_NAME, RG_LAYER_NAME,
RG_LAYER_OBJECT_GROUP_NAME, RG_LAYER_OBJECT_GROUP_NAME,
@ -30,6 +32,7 @@ import { getScaledFlooredCursorPosition, snapPosToStage } from 'features/control
import { import {
isControlAdapterLayer, isControlAdapterLayer,
isInitialImageLayer, isInitialImageLayer,
isRasterLayer,
isRegionalGuidanceLayer, isRegionalGuidanceLayer,
isRenderableLayer, isRenderableLayer,
} from 'features/controlLayers/store/controlLayersSlice'; } from 'features/controlLayers/store/controlLayersSlice';
@ -39,15 +42,17 @@ import type {
EraserLine, EraserLine,
InitialImageLayer, InitialImageLayer,
Layer, Layer,
RasterLayer,
RectShape, RectShape,
RegionalGuidanceLayer, RegionalGuidanceLayer,
RgbaColor,
Tool, Tool,
} from 'features/controlLayers/store/types'; } from 'features/controlLayers/store/types';
import { DEFAULT_RGBA_COLOR } from 'features/controlLayers/store/types';
import { t } from 'i18next'; import { t } from 'i18next';
import Konva from 'konva'; import Konva from 'konva';
import type { IRect, Vector2d } from 'konva/lib/types'; import type { IRect, Vector2d } from 'konva/lib/types';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import type { RgbColor } from 'react-colorful';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -59,21 +64,6 @@ import {
TRANSPARENCY_CHECKER_PATTERN, TRANSPARENCY_CHECKER_PATTERN,
} from './constants'; } from './constants';
const mapId = (object: { id: string }): string => object.id;
/**
* Konva selection callback to select all renderable layers. This includes RG, CA and II layers.
*/
const selectRenderableLayers = (n: Konva.Node): boolean =>
n.name() === RG_LAYER_NAME || n.name() === CA_LAYER_NAME || n.name() === INITIAL_IMAGE_LAYER_NAME;
/**
* Konva selection callback to select RG mask objects. This includes lines and rects.
*/
const selectVectorMaskObjects = (node: Konva.Node): boolean => {
return node.name() === RG_LAYER_LINE_NAME || node.name() === RG_LAYER_RECT_NAME;
};
/** /**
* Creates the singleton tool preview layer and all its objects. * Creates the singleton tool preview layer and all its objects.
* @param stage The konva stage * @param stage The konva stage
@ -130,7 +120,7 @@ const createToolPreviewLayer = (stage: Konva.Stage): Konva.Layer => {
const renderToolPreview = ( const renderToolPreview = (
stage: Konva.Stage, stage: Konva.Stage,
tool: Tool, tool: Tool,
color: RgbColor | null, brushColor: RgbaColor,
selectedLayerType: Layer['type'] | null, selectedLayerType: Layer['type'] | null,
globalMaskLayerOpacity: number, globalMaskLayerOpacity: number,
cursorPos: Vector2d | null, cursorPos: Vector2d | null,
@ -142,7 +132,7 @@ const renderToolPreview = (
if (layerCount === 0) { if (layerCount === 0) {
// We have no layers, so we should not render any tool // We have no layers, so we should not render any tool
stage.container().style.cursor = 'default'; stage.container().style.cursor = 'default';
} else if (selectedLayerType !== 'regional_guidance_layer') { } else if (selectedLayerType !== 'regional_guidance_layer' && selectedLayerType !== 'raster_layer') {
// Non-mask-guidance layers don't have tools // Non-mask-guidance layers don't have tools
stage.container().style.cursor = 'not-allowed'; stage.container().style.cursor = 'not-allowed';
} else if (tool === 'move') { } else if (tool === 'move') {
@ -173,14 +163,14 @@ const renderToolPreview = (
assert(rectPreview, 'Rect preview not found'); assert(rectPreview, 'Rect preview not found');
// No need to render the brush preview if the cursor position or color is missing // No need to render the brush preview if the cursor position or color is missing
if (cursorPos && color && (tool === 'brush' || tool === 'eraser')) { if (cursorPos && (tool === 'brush' || tool === 'eraser')) {
// Update the fill circle // Update the fill circle
const brushPreviewFill = brushPreviewGroup.findOne<Konva.Circle>(`#${TOOL_PREVIEW_BRUSH_FILL_ID}`); const brushPreviewFill = brushPreviewGroup.findOne<Konva.Circle>(`#${TOOL_PREVIEW_BRUSH_FILL_ID}`);
brushPreviewFill?.setAttrs({ brushPreviewFill?.setAttrs({
x: cursorPos.x, x: cursorPos.x,
y: cursorPos.y, y: cursorPos.y,
radius: brushSize / 2, radius: brushSize / 2,
fill: rgbaColorToString({ ...color, a: globalMaskLayerOpacity }), fill: rgbaColorToString(brushColor),
globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out', globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out',
}); });
@ -263,7 +253,7 @@ const createRGLayer = (
// The object group holds all of the layer's objects (e.g. lines and rects) // The object group holds all of the layer's objects (e.g. lines and rects)
const konvaObjectGroup = new Konva.Group({ const konvaObjectGroup = new Konva.Group({
id: getRGLayerObjectGroupId(layerState.id, uuidv4()), id: getObjectGroupId(layerState.id, uuidv4()),
name: RG_LAYER_OBJECT_GROUP_NAME, name: RG_LAYER_OBJECT_GROUP_NAME,
listening: false, listening: false,
}); });
@ -273,13 +263,14 @@ const createRGLayer = (
return konvaLayer; return konvaLayer;
}; };
//#endregion
/** /**
* Creates a konva vector mask brush line from a vector mask line. * Creates a konva line for a brush line.
* @param brushLine The vector mask line state * @param brushLine The brush line state
* @param layerObjectGroup The konva layer's object group to add the line to * @param layerObjectGroup The konva layer's object group to add the line to
*/ */
const createVectorMaskBrushLine = (brushLine: BrushLine, layerObjectGroup: Konva.Group): Konva.Line => { const createBrushLine = (brushLine: BrushLine, layerObjectGroup: Konva.Group): Konva.Line => {
const konvaLine = new Konva.Line({ const konvaLine = new Konva.Line({
id: brushLine.id, id: brushLine.id,
key: brushLine.id, key: brushLine.id,
@ -291,17 +282,18 @@ const createVectorMaskBrushLine = (brushLine: BrushLine, layerObjectGroup: Konva
shadowForStrokeEnabled: false, shadowForStrokeEnabled: false,
globalCompositeOperation: 'source-over', globalCompositeOperation: 'source-over',
listening: false, listening: false,
stroke: rgbaColorToString(brushLine.color),
}); });
layerObjectGroup.add(konvaLine); layerObjectGroup.add(konvaLine);
return konvaLine; return konvaLine;
}; };
/** /**
* Creates a konva vector mask eraser line from a vector mask line. * Creates a konva line for a eraser line.
* @param eraserLine The vector mask line state * @param eraserLine The eraser line state
* @param layerObjectGroup The konva layer's object group to add the line to * @param layerObjectGroup The konva layer's object group to add the line to
*/ */
const createVectorMaskEraserLine = (eraserLine: EraserLine, layerObjectGroup: Konva.Group): Konva.Line => { const createEraserLine = (eraserLine: EraserLine, layerObjectGroup: Konva.Group): Konva.Line => {
const konvaLine = new Konva.Line({ const konvaLine = new Konva.Line({
id: eraserLine.id, id: eraserLine.id,
key: eraserLine.id, key: eraserLine.id,
@ -313,42 +305,35 @@ const createVectorMaskEraserLine = (eraserLine: EraserLine, layerObjectGroup: Ko
shadowForStrokeEnabled: false, shadowForStrokeEnabled: false,
globalCompositeOperation: 'destination-out', globalCompositeOperation: 'destination-out',
listening: false, listening: false,
stroke: rgbaColorToString(DEFAULT_RGBA_COLOR),
}); });
layerObjectGroup.add(konvaLine); layerObjectGroup.add(konvaLine);
return konvaLine; return konvaLine;
}; };
const createVectorMaskLine = (maskObject: BrushLine | EraserLine, layerObjectGroup: Konva.Group): Konva.Line => {
if (maskObject.type === 'brush_line') {
return createVectorMaskBrushLine(maskObject, layerObjectGroup);
} else {
// maskObject.type === 'eraser_line'
return createVectorMaskEraserLine(maskObject, layerObjectGroup);
}
};
/** /**
* Creates a konva rect from a vector mask rect. * Creates a konva rect for a rect shape.
* @param vectorMaskRect The vector mask rect state * @param rectShape The rect shape state
* @param layerObjectGroup The konva layer's object group to add the line to * @param layerObjectGroup The konva layer's object group to add the line to
*/ */
const createVectorMaskRect = (vectorMaskRect: RectShape, layerObjectGroup: Konva.Group): Konva.Rect => { const createRectShape = (rectShape: RectShape, layerObjectGroup: Konva.Group): Konva.Rect => {
const konvaRect = new Konva.Rect({ const konvaRect = new Konva.Rect({
id: vectorMaskRect.id, id: rectShape.id,
key: vectorMaskRect.id, key: rectShape.id,
name: RG_LAYER_RECT_NAME, name: RG_LAYER_RECT_NAME,
x: vectorMaskRect.x, x: rectShape.x,
y: vectorMaskRect.y, y: rectShape.y,
width: vectorMaskRect.width, width: rectShape.width,
height: vectorMaskRect.height, height: rectShape.height,
listening: false, listening: false,
fill: rgbaColorToString(rectShape.color),
}); });
layerObjectGroup.add(konvaRect); layerObjectGroup.add(konvaRect);
return konvaRect; return konvaRect;
}; };
/** /**
* Creates the "compositing rect" for a layer. * Creates the "compositing rect" for a regional guidance layer.
* @param konvaLayer The konva layer * @param konvaLayer The konva layer
*/ */
const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => { const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => {
@ -358,7 +343,7 @@ const createCompositingRect = (konvaLayer: Konva.Layer): Konva.Rect => {
}; };
/** /**
* Renders a regional guidance layer. * Renders a raster layer.
* @param stage The konva stage * @param stage The konva stage
* @param layerState The regional guidance layer state * @param layerState The regional guidance layer state
* @param globalMaskLayerOpacity The global mask layer opacity * @param globalMaskLayerOpacity The global mask layer opacity
@ -391,7 +376,7 @@ const renderRGLayer = (
// We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required. // We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
let groupNeedsCache = false; let groupNeedsCache = false;
const objectIds = layerState.maskObjects.map(mapId); const objectIds = layerState.objects.map(mapId);
// Destroy any objects that are no longer in the redux state // Destroy any objects that are no longer in the redux state
for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) { for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) {
if (!objectIds.includes(objectNode.id())) { if (!objectIds.includes(objectNode.id())) {
@ -400,29 +385,41 @@ const renderRGLayer = (
} }
} }
for (const maskObject of layerState.maskObjects) { for (const obj of layerState.objects) {
if (maskObject.type === 'brush_line' || maskObject.type === 'eraser_line') { if (obj.type === 'brush_line') {
const vectorMaskLine = const konvaBrushLine = stage.findOne<Konva.Line>(`#${obj.id}`) ?? createBrushLine(obj, konvaObjectGroup);
stage.findOne<Konva.Line>(`#${maskObject.id}`) ?? createVectorMaskLine(maskObject, konvaObjectGroup);
// Only update the points if they have changed. The point values are never mutated, they are only added to the // Only update the points if they have changed. The point values are never mutated, they are only added to the
// array, so checking the length is sufficient to determine if we need to re-cache. // array, so checking the length is sufficient to determine if we need to re-cache.
if (vectorMaskLine.points().length !== maskObject.points.length) { if (konvaBrushLine.points().length !== obj.points.length) {
vectorMaskLine.points(maskObject.points); konvaBrushLine.points(obj.points);
groupNeedsCache = true; groupNeedsCache = true;
} }
// Only update the color if it has changed. // Only update the color if it has changed.
if (vectorMaskLine.stroke() !== rgbColor) { if (konvaBrushLine.stroke() !== rgbColor) {
vectorMaskLine.stroke(rgbColor); konvaBrushLine.stroke(rgbColor);
groupNeedsCache = true; groupNeedsCache = true;
} }
} else if (maskObject.type === 'rect_shape') { } else if (obj.type === 'eraser_line') {
const konvaObject = const konvaEraserLine = stage.findOne<Konva.Line>(`#${obj.id}`) ?? createEraserLine(obj, konvaObjectGroup);
stage.findOne<Konva.Rect>(`#${maskObject.id}`) ?? createVectorMaskRect(maskObject, konvaObjectGroup);
// Only update the points if they have changed. The point values are never mutated, they are only added to the
// array, so checking the length is sufficient to determine if we need to re-cache.
if (konvaEraserLine.points().length !== obj.points.length) {
konvaEraserLine.points(obj.points);
groupNeedsCache = true;
}
// Only update the color if it has changed.
if (konvaEraserLine.stroke() !== rgbColor) {
konvaEraserLine.stroke(rgbColor);
groupNeedsCache = true;
}
} else if (obj.type === 'rect_shape') {
const konvaRectShape = stage.findOne<Konva.Rect>(`#${obj.id}`) ?? createRectShape(obj, konvaObjectGroup);
// Only update the color if it has changed. // Only update the color if it has changed.
if (konvaObject.fill() !== rgbColor) { if (konvaRectShape.fill() !== rgbColor) {
konvaObject.fill(rgbColor); konvaRectShape.fill(rgbColor);
groupNeedsCache = true; groupNeedsCache = true;
} }
} }
@ -485,6 +482,126 @@ const renderRGLayer = (
} }
}; };
/**
* Creates a raster layer.
* @param stage The konva stage
* @param layerState The raster layer state
* @param onLayerPosChanged Callback for when the layer's position changes
*/
const createRasterLayer = (
stage: Konva.Stage,
layerState: RasterLayer,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
): Konva.Layer => {
// This layer hasn't been added to the konva state yet
const konvaLayer = new Konva.Layer({
id: layerState.id,
name: RASTER_LAYER_NAME,
draggable: true,
dragDistance: 0,
});
// When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
// the position - we do not need to call this on the `dragmove` event.
if (onLayerPosChanged) {
konvaLayer.on('dragend', function (e) {
onLayerPosChanged(layerState.id, Math.floor(e.target.x()), Math.floor(e.target.y()));
});
}
// The dragBoundFunc limits how far the layer can be dragged
konvaLayer.dragBoundFunc(function (pos) {
const cursorPos = getScaledFlooredCursorPosition(stage);
if (!cursorPos) {
return this.getAbsolutePosition();
}
// Prevent the user from dragging the layer out of the stage bounds by constaining the cursor position to the stage bounds
if (
cursorPos.x < 0 ||
cursorPos.x > stage.width() / stage.scaleX() ||
cursorPos.y < 0 ||
cursorPos.y > stage.height() / stage.scaleY()
) {
return this.getAbsolutePosition();
}
return pos;
});
// The object group holds all of the layer's objects (e.g. lines and rects)
const konvaObjectGroup = new Konva.Group({
id: getObjectGroupId(layerState.id, uuidv4()),
name: RASTER_LAYER_OBJECT_GROUP_NAME,
listening: false,
});
konvaLayer.add(konvaObjectGroup);
stage.add(konvaLayer);
return konvaLayer;
};
/**
* Renders a regional guidance layer.
* @param stage The konva stage
* @param layerState The regional guidance layer state
* @param tool The current tool
* @param onLayerPosChanged Callback for when the layer's position changes
*/
const renderRasterLayer = (
stage: Konva.Stage,
layerState: RasterLayer,
tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
): void => {
const konvaLayer =
stage.findOne<Konva.Layer>(`#${layerState.id}`) ?? createRasterLayer(stage, layerState, onLayerPosChanged);
// Update the layer's position and listening state
konvaLayer.setAttrs({
listening: tool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events
x: Math.floor(layerState.x),
y: Math.floor(layerState.y),
});
const konvaObjectGroup = konvaLayer.findOne<Konva.Group>(`.${RASTER_LAYER_OBJECT_GROUP_NAME}`);
assert(konvaObjectGroup, `Object group not found for layer ${layerState.id}`);
const objectIds = layerState.objects.map(mapId);
// Destroy any objects that are no longer in the redux state
for (const objectNode of konvaObjectGroup.getChildren()) {
if (!objectIds.includes(objectNode.id())) {
objectNode.destroy();
}
}
for (const obj of layerState.objects) {
if (obj.type === 'brush_line') {
const konvaBrushLine = stage.findOne<Konva.Line>(`#${obj.id}`) ?? createBrushLine(obj, konvaObjectGroup);
// Only update the points if they have changed.
if (konvaBrushLine.points().length !== obj.points.length) {
konvaBrushLine.points(obj.points);
}
} else if (obj.type === 'eraser_line') {
const konvaEraserLine = stage.findOne<Konva.Line>(`#${obj.id}`) ?? createEraserLine(obj, konvaObjectGroup);
// Only update the points if they have changed.
if (konvaEraserLine.points().length !== obj.points.length) {
konvaEraserLine.points(obj.points);
}
} else if (obj.type === 'rect_shape') {
if (!stage.findOne<Konva.Rect>(`#${obj.id}`)) {
createRectShape(obj, konvaObjectGroup);
}
}
}
// Only update layer visibility if it has changed.
if (konvaLayer.visible() !== layerState.isEnabled) {
konvaLayer.visible(layerState.isEnabled);
}
konvaObjectGroup.opacity(layerState.opacity);
};
/** /**
* Creates an initial image konva layer. * Creates an initial image konva layer.
* @param stage The konva stage * @param stage The konva stage
@ -805,6 +922,9 @@ const renderLayers = (
if (isInitialImageLayer(layer)) { if (isInitialImageLayer(layer)) {
renderIILayer(stage, layer, getImageDTO); renderIILayer(stage, layer, getImageDTO);
} }
if (isRasterLayer(layer)) {
renderRasterLayer(stage, layer, tool, onLayerPosChanged);
}
// IP Adapter layers are not rendered // IP Adapter layers are not rendered
} }
}; };
@ -886,7 +1006,7 @@ const updateBboxes = (
const visible = bboxRect.visible(); const visible = bboxRect.visible();
bboxRect.visible(false); bboxRect.visible(false);
if (rgLayer.maskObjects.length === 0) { if (rgLayer.objects.length === 0) {
// No objects - no bbox to calculate // No objects - no bbox to calculate
onBboxChanged(rgLayer.id, null); onBboxChanged(rgLayer.id, null);
} else { } else {
@ -1041,3 +1161,23 @@ export const debouncedRenderers = {
arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS), arrangeLayers: debounce(arrangeLayers, DEBOUNCE_MS),
updateBboxes: debounce(updateBboxes, DEBOUNCE_MS), updateBboxes: debounce(updateBboxes, DEBOUNCE_MS),
}; };
//#region util
const mapId = (object: { id: string }): string => object.id;
/**
* Konva selection callback to select all renderable layers. This includes RG, CA and II layers.
*/
const selectRenderableLayers = (n: Konva.Node): boolean =>
n.name() === RG_LAYER_NAME ||
n.name() === CA_LAYER_NAME ||
n.name() === INITIAL_IMAGE_LAYER_NAME ||
n.name() === RASTER_LAYER_NAME;
/**
* Konva selection callback to select RG mask objects. This includes lines and rects.
*/
const selectVectorMaskObjects = (node: Konva.Node): boolean => {
return node.name() === RG_LAYER_LINE_NAME || node.name() === RG_LAYER_RECT_NAME;
};
//#endregion

View File

@ -5,12 +5,13 @@ import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/
import { deepClone } from 'common/util/deepClone'; import { deepClone } from 'common/util/deepClone';
import { roundDownToMultiple } from 'common/util/roundDownToMultiple'; import { roundDownToMultiple } from 'common/util/roundDownToMultiple';
import { import {
getBrushLineId,
getCALayerId, getCALayerId,
getEraserLineId,
getIPALayerId, getIPALayerId,
getRasterLayerId, getRasterLayerId,
getRectId,
getRGLayerId, getRGLayerId,
getRGLayerLineId,
getRGLayerRectId,
INITIAL_IMAGE_LAYER_ID, INITIAL_IMAGE_LAYER_ID,
} from 'features/controlLayers/konva/naming'; } from 'features/controlLayers/konva/naming';
import type { import type {
@ -45,20 +46,24 @@ import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import type { import type {
AddLineArg, AddBrushLineArg,
AddEraserLineArg,
AddPointToLineArg, AddPointToLineArg,
AddRectArg, AddRectShapeArg,
BrushLine, BrushLine,
ControlAdapterLayer, ControlAdapterLayer,
ControlLayersState, ControlLayersState,
DrawingTool, EllipseShape,
EraserLine, EraserLine,
ImageObject,
InitialImageLayer, InitialImageLayer,
IPAdapterLayer, IPAdapterLayer,
Layer, Layer,
PolygonShape,
RasterLayer, RasterLayer,
RectShape, RectShape,
RegionalGuidanceLayer, RegionalGuidanceLayer,
RgbaColor,
Tool, Tool,
} from './types'; } from './types';
import { DEFAULT_RGBA_COLOR } from './types'; import { DEFAULT_RGBA_COLOR } from './types';
@ -67,6 +72,7 @@ export const initialControlLayersState: ControlLayersState = {
_version: 3, _version: 3,
selectedLayerId: null, selectedLayerId: null,
brushSize: 100, brushSize: 100,
brushColor: DEFAULT_RGBA_COLOR,
layers: [], layers: [],
globalMaskLayerOpacity: 0.3, // this globally changes all mask layers' opacity globalMaskLayerOpacity: 0.3, // this globally changes all mask layers' opacity
positivePrompt: '', positivePrompt: '',
@ -81,8 +87,9 @@ export const initialControlLayersState: ControlLayersState = {
}, },
}; };
const isLine = (obj: BrushLine | EraserLine | RectShape): obj is BrushLine | EraserLine => const isLine = (
obj.type === 'brush_line' || obj.type === 'eraser_line'; obj: BrushLine | EraserLine | RectShape | EllipseShape | PolygonShape | ImageObject
): obj is BrushLine => obj.type === 'brush_line' || obj.type === 'eraser_line';
export const isRegionalGuidanceLayer = (layer?: Layer): layer is RegionalGuidanceLayer => export const isRegionalGuidanceLayer = (layer?: Layer): layer is RegionalGuidanceLayer =>
layer?.type === 'regional_guidance_layer'; layer?.type === 'regional_guidance_layer';
export const isControlAdapterLayer = (layer?: Layer): layer is ControlAdapterLayer => export const isControlAdapterLayer = (layer?: Layer): layer is ControlAdapterLayer =>
@ -131,6 +138,14 @@ const selectRGLayerOrThrow = (state: ControlLayersState, layerId: string): Regio
assert(isRegionalGuidanceLayer(layer)); assert(isRegionalGuidanceLayer(layer));
return layer; return layer;
}; };
const selectRGOrRasterLayerOrThrow = (
state: ControlLayersState,
layerId: string
): RegionalGuidanceLayer | RasterLayer => {
const layer = state.layers.find((l) => l.id === layerId);
assert(isRegionalGuidanceLayer(layer) || isRasterLayer(layer));
return layer;
};
export const selectRGLayerIPAdapterOrThrow = ( export const selectRGLayerIPAdapterOrThrow = (
state: ControlLayersState, state: ControlLayersState,
layerId: string, layerId: string,
@ -187,7 +202,7 @@ export const controlLayersSlice = createSlice({
layer.bboxNeedsUpdate = false; layer.bboxNeedsUpdate = false;
if (bbox === null && layer.type === 'regional_guidance_layer') { if (bbox === null && layer.type === 'regional_guidance_layer') {
// The layer was fully erased, empty its objects to prevent accumulation of invisible objects // The layer was fully erased, empty its objects to prevent accumulation of invisible objects
layer.maskObjects = []; layer.objects = [];
layer.uploadedMaskImage = null; layer.uploadedMaskImage = null;
} }
} }
@ -196,7 +211,7 @@ export const controlLayersSlice = createSlice({
const layer = state.layers.find((l) => l.id === action.payload); const layer = state.layers.find((l) => l.id === action.payload);
// TODO(psyche): Should other layer types also have reset functionality? // TODO(psyche): Should other layer types also have reset functionality?
if (isRegionalGuidanceLayer(layer)) { if (isRegionalGuidanceLayer(layer)) {
layer.maskObjects = []; layer.objects = [];
layer.bbox = null; layer.bbox = null;
layer.isEnabled = true; layer.isEnabled = true;
layer.bboxNeedsUpdate = false; layer.bboxNeedsUpdate = false;
@ -455,7 +470,7 @@ export const controlLayersSlice = createSlice({
isEnabled: true, isEnabled: true,
bbox: null, bbox: null,
bboxNeedsUpdate: false, bboxNeedsUpdate: false,
maskObjects: [], objects: [],
previewColor: getVectorMaskPreviewColor(state), previewColor: getVectorMaskPreviewColor(state),
x: 0, x: 0,
y: 0, y: 0,
@ -490,81 +505,102 @@ export const controlLayersSlice = createSlice({
const layer = selectRGLayerOrThrow(state, layerId); const layer = selectRGLayerOrThrow(state, layerId);
layer.previewColor = color; layer.previewColor = color;
}, },
rgLayerLineAdded: { brushLineAdded: {
reducer: ( reducer: (
state, state,
action: PayloadAction<{ action: PayloadAction<
layerId: string; AddBrushLineArg & {
points: [number, number, number, number];
tool: DrawingTool;
lineUuid: string; lineUuid: string;
}> }
>
) => { ) => {
const { layerId, points, tool, lineUuid } = action.payload; const { layerId, points, lineUuid, color } = action.payload;
const layer = selectRGLayerOrThrow(state, layerId); const layer = selectRGOrRasterLayerOrThrow(state, layerId);
const lineId = getRGLayerLineId(layer.id, lineUuid); layer.objects.push({
if (tool === 'brush') { id: getBrushLineId(layer.id, lineUuid),
layer.maskObjects.push({
id: lineId,
type: 'brush_line', type: 'brush_line',
// Points must be offset by the layer's x and y coordinates // Points must be offset by the layer's x and y coordinates
// TODO: Handle this in the event listener? // TODO: Handle this in the event listener?
points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y], points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
strokeWidth: state.brushSize, strokeWidth: state.brushSize,
color: DEFAULT_RGBA_COLOR, color,
}); });
} else { layer.bboxNeedsUpdate = true;
layer.maskObjects.push({ if (layer.type === 'regional_guidance_layer') {
id: lineId, layer.uploadedMaskImage = null;
}
},
prepare: (payload: AddBrushLineArg) => ({
payload: { ...payload, lineUuid: uuidv4() },
}),
},
eraserLineAdded: {
reducer: (
state,
action: PayloadAction<
AddEraserLineArg & {
lineUuid: string;
}
>
) => {
const { layerId, points, lineUuid } = action.payload;
const layer = selectRGOrRasterLayerOrThrow(state, layerId);
layer.objects.push({
id: getEraserLineId(layer.id, lineUuid),
type: 'eraser_line', type: 'eraser_line',
// Points must be offset by the layer's x and y coordinates // Points must be offset by the layer's x and y coordinates
// TODO: Handle this in the event listener? // TODO: Handle this in the event listener?
points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y], points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
strokeWidth: state.brushSize, strokeWidth: state.brushSize,
}); });
}
layer.bboxNeedsUpdate = true; layer.bboxNeedsUpdate = true;
if (isRegionalGuidanceLayer(layer)) {
layer.uploadedMaskImage = null; layer.uploadedMaskImage = null;
}
}, },
prepare: (payload: AddLineArg) => ({ prepare: (payload: AddEraserLineArg) => ({
payload: { ...payload, lineUuid: uuidv4() }, payload: { ...payload, lineUuid: uuidv4() },
}), }),
}, },
rgLayerPointsAdded: (state, action: PayloadAction<AddPointToLineArg>) => { linePointsAdded: (state, action: PayloadAction<AddPointToLineArg>) => {
const { layerId, point } = action.payload; const { layerId, point } = action.payload;
const layer = selectRGLayerOrThrow(state, layerId); const layer = selectRGOrRasterLayerOrThrow(state, layerId);
const lastLine = layer.maskObjects.findLast(isLine); const lastLine = layer.objects.findLast(isLine);
if (!lastLine) { if (!lastLine || !isLine(lastLine)) {
return; return;
} }
// Points must be offset by the layer's x and y coordinates // Points must be offset by the layer's x and y coordinates
// TODO: Handle this in the event listener // TODO: Handle this in the event listener
lastLine.points.push(point[0] - layer.x, point[1] - layer.y); lastLine.points.push(point[0] - layer.x, point[1] - layer.y);
layer.bboxNeedsUpdate = true; layer.bboxNeedsUpdate = true;
if (isRegionalGuidanceLayer(layer)) {
layer.uploadedMaskImage = null; layer.uploadedMaskImage = null;
}
}, },
rgLayerRectAdded: { rectAdded: {
reducer: (state, action: PayloadAction<{ layerId: string; rect: IRect; rectUuid: string }>) => { reducer: (state, action: PayloadAction<AddRectShapeArg & { rectUuid: string }>) => {
const { layerId, rect, rectUuid } = action.payload; const { layerId, rect, rectUuid, color } = action.payload;
if (rect.height === 0 || rect.width === 0) { if (rect.height === 0 || rect.width === 0) {
// Ignore zero-area rectangles // Ignore zero-area rectangles
return; return;
} }
const layer = selectRGLayerOrThrow(state, layerId); const layer = selectRGOrRasterLayerOrThrow(state, layerId);
const id = getRGLayerRectId(layer.id, rectUuid); const id = getRectId(layer.id, rectUuid);
layer.maskObjects.push({ layer.objects.push({
type: 'rect_shape', type: 'rect_shape',
id, id,
x: rect.x - layer.x, x: rect.x - layer.x,
y: rect.y - layer.y, y: rect.y - layer.y,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
color: DEFAULT_RGBA_COLOR, color,
}); });
layer.bboxNeedsUpdate = true; layer.bboxNeedsUpdate = true;
if (isRegionalGuidanceLayer(layer)) {
layer.uploadedMaskImage = null; layer.uploadedMaskImage = null;
}
}, },
prepare: (payload: AddRectArg) => ({ payload: { ...payload, rectUuid: uuidv4() } }), prepare: (payload: AddRectShapeArg) => ({ payload: { ...payload, rectUuid: uuidv4() } }),
}, },
rgLayerMaskImageUploaded: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO }>) => { rgLayerMaskImageUploaded: (state, action: PayloadAction<{ layerId: string; imageDTO: ImageDTO }>) => {
const { layerId, imageDTO } = action.payload; const { layerId, imageDTO } = action.payload;
@ -776,6 +812,9 @@ export const controlLayersSlice = createSlice({
brushSizeChanged: (state, action: PayloadAction<number>) => { brushSizeChanged: (state, action: PayloadAction<number>) => {
state.brushSize = Math.round(action.payload); state.brushSize = Math.round(action.payload);
}, },
brushColorChanged: (state, action: PayloadAction<RgbaColor>) => {
state.brushColor = action.payload;
},
globalMaskLayerOpacityChanged: (state, action: PayloadAction<number>) => { globalMaskLayerOpacityChanged: (state, action: PayloadAction<number>) => {
state.globalMaskLayerOpacity = action.payload; state.globalMaskLayerOpacity = action.payload;
}, },
@ -892,9 +931,10 @@ export const {
rgLayerPositivePromptChanged, rgLayerPositivePromptChanged,
rgLayerNegativePromptChanged, rgLayerNegativePromptChanged,
rgLayerPreviewColorChanged, rgLayerPreviewColorChanged,
rgLayerLineAdded, brushLineAdded,
rgLayerPointsAdded, eraserLineAdded,
rgLayerRectAdded, linePointsAdded,
rectAdded,
rgLayerMaskImageUploaded, rgLayerMaskImageUploaded,
rgLayerAutoNegativeChanged, rgLayerAutoNegativeChanged,
rgLayerIPAdapterAdded, rgLayerIPAdapterAdded,
@ -924,6 +964,7 @@ export const {
heightChanged, heightChanged,
aspectRatioChanged, aspectRatioChanged,
brushSizeChanged, brushSizeChanged,
brushColorChanged,
globalMaskLayerOpacityChanged, globalMaskLayerOpacityChanged,
undo, undo,
redo, redo,
@ -960,9 +1001,9 @@ export const $lastAddedPoint = atom<Vector2d | null>(null);
// Some nanostores that are manually synced to redux state to provide imperative access // Some nanostores that are manually synced to redux state to provide imperative access
// TODO(psyche): This is a hack, figure out another way to handle this... // TODO(psyche): This is a hack, figure out another way to handle this...
export const $brushSize = atom<number>(0); export const $brushSize = atom<number>(0);
export const $brushColor = atom<RgbaColor>(DEFAULT_RGBA_COLOR);
export const $brushSpacingPx = atom<number>(0); export const $brushSpacingPx = atom<number>(0);
export const $selectedLayerId = atom<string | null>(null); export const $selectedLayer = atom<Layer | null>(null);
export const $selectedLayerType = atom<Layer['type'] | null>(null);
export const $shouldInvertBrushSizeScrollDirection = atom(false); export const $shouldInvertBrushSizeScrollDirection = atom(false);
export const controlLayersPersistConfig: PersistConfig<ControlLayersState> = { export const controlLayersPersistConfig: PersistConfig<ControlLayersState> = {
@ -998,10 +1039,10 @@ export const controlLayersUndoableConfig: UndoableOptions<ControlLayersState, Un
// Lines are started with `rgLayerLineAdded` and may have any number of subsequent `rgLayerPointsAdded` events. // Lines are started with `rgLayerLineAdded` and may have any number of subsequent `rgLayerPointsAdded` events.
// We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping // We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping
// separate logical lines as a single undo action. // separate logical lines as a single undo action.
if (rgLayerLineAdded.match(action)) { if (brushLineAdded.match(action)) {
return history.group === LINE_1 ? LINE_2 : LINE_1; return history.group === LINE_1 ? LINE_2 : LINE_1;
} }
if (rgLayerPointsAdded.match(action)) { if (linePointsAdded.match(action)) {
if (history.group === LINE_1 || history.group === LINE_2) { if (history.group === LINE_1 || history.group === LINE_2) {
return history.group; return history.group;
} }
@ -1012,15 +1053,6 @@ export const controlLayersUndoableConfig: UndoableOptions<ControlLayersState, Un
return null; return null;
}, },
filter: (action, _state, _history) => { filter: (action, _state, _history) => {
// Ignore all actions from other slices
if (!action.type.startsWith(controlLayersSlice.name)) {
return false; return false;
}
// This action is triggered on state changes, including when we undo. If we do not ignore this action, when we
// undo, this action triggers and empties the future states array. Therefore, we must ignore this action.
if (layerBboxChanged.match(action)) {
return false;
}
return true;
}, },
}; };

View File

@ -55,7 +55,7 @@ const zRgbColor = z.object({
const zRgbaColor = zRgbColor.extend({ const zRgbaColor = zRgbColor.extend({
a: z.number().min(0).max(1), a: z.number().min(0).max(1),
}); });
type RgbaColor = z.infer<typeof zRgbaColor>; export type RgbaColor = z.infer<typeof zRgbaColor>;
export const DEFAULT_RGBA_COLOR: RgbaColor = { r: 255, g: 255, b: 255, a: 1 }; export const DEFAULT_RGBA_COLOR: RgbaColor = { r: 255, g: 255, b: 255, a: 1 };
const zOpacity = z.number().gte(0).lte(1); const zOpacity = z.number().gte(0).lte(1);
@ -193,7 +193,7 @@ const zMaskObject = z
}) })
.pipe(z.discriminatedUnion('type', [zBrushLine, zEraserline, zRectShape])); .pipe(z.discriminatedUnion('type', [zBrushLine, zEraserline, zRectShape]));
const zRegionalGuidanceLayer = zRenderableLayerBase.extend({ const zOLD_RegionalGuidanceLayer = zRenderableLayerBase.extend({
type: z.literal('regional_guidance_layer'), type: z.literal('regional_guidance_layer'),
maskObjects: z.array(zMaskObject), maskObjects: z.array(zMaskObject),
positivePrompt: zParameterPositivePrompt.nullable(), positivePrompt: zParameterPositivePrompt.nullable(),
@ -203,7 +203,28 @@ const zRegionalGuidanceLayer = zRenderableLayerBase.extend({
autoNegative: zAutoNegative, autoNegative: zAutoNegative,
uploadedMaskImage: zImageWithDims.nullable(), uploadedMaskImage: zImageWithDims.nullable(),
}); });
export type RegionalGuidanceLayer = z.infer<typeof zRegionalGuidanceLayer>; const zRegionalGuidanceLayer = zRenderableLayerBase.extend({
type: z.literal('regional_guidance_layer'),
objects: z.array(zMaskObject),
positivePrompt: zParameterPositivePrompt.nullable(),
negativePrompt: zParameterNegativePrompt.nullable(),
ipAdapters: z.array(zIPAdapterConfigV2),
previewColor: zRgbColor,
autoNegative: zAutoNegative,
uploadedMaskImage: zImageWithDims.nullable(),
});
const zRGLayer = z
.union([zOLD_RegionalGuidanceLayer, zRegionalGuidanceLayer])
.transform((val) => {
if ('maskObjects' in val) {
const { maskObjects, ...rest } = val;
return { ...rest, objects: maskObjects };
} else {
return val;
}
})
.pipe(zRegionalGuidanceLayer);
export type RegionalGuidanceLayer = z.infer<typeof zRGLayer>;
const zInitialImageLayer = zRenderableLayerBase.extend({ const zInitialImageLayer = zRenderableLayerBase.extend({
type: z.literal('initial_image_layer'), type: z.literal('initial_image_layer'),
@ -227,6 +248,7 @@ export type ControlLayersState = {
selectedLayerId: string | null; selectedLayerId: string | null;
layers: Layer[]; layers: Layer[];
brushSize: number; brushSize: number;
brushColor: RgbaColor;
globalMaskLayerOpacity: number; globalMaskLayerOpacity: number;
positivePrompt: ParameterPositivePrompt; positivePrompt: ParameterPositivePrompt;
negativePrompt: ParameterNegativePrompt; negativePrompt: ParameterNegativePrompt;
@ -240,6 +262,7 @@ export type ControlLayersState = {
}; };
}; };
export type AddLineArg = { layerId: string; points: [number, number, number, number]; tool: DrawingTool }; export type AddEraserLineArg = { layerId: string; points: [number, number, number, number] };
export type AddBrushLineArg = AddEraserLineArg & { color: RgbaColor };
export type AddPointToLineArg = { layerId: string; point: [number, number] }; export type AddPointToLineArg = { layerId: string; point: [number, number] };
export type AddRectArg = { layerId: string; rect: IRect }; export type AddRectShapeArg = { layerId: string; rect: IRect; color: RgbaColor };