Compare commits

..

16 Commits

Author SHA1 Message Date
psychedelicious
056c56d322 chore: release v4.2.9.dev20240824 2024-08-24 12:36:25 +10:00
psychedelicious
afc6f83d72 fix(ui): lint & fix issues with adding regional ip adapters 2024-08-24 12:32:38 +10:00
psychedelicious
c776ac3af2 feat(ui): add knipignore tag
I'm not ready to delete some things but still want to build the app.
2024-08-24 12:32:00 +10:00
psychedelicious
b7b3683bef feat(ui): duplicate entity 2024-08-24 12:20:35 +10:00
psychedelicious
fb26b6824a feat(ui): autocomplete on getPrefixeId 2024-08-24 12:20:26 +10:00
psychedelicious
63d8ad912f feat(ui): paste canvas gens back on source in generate mode 2024-08-24 11:56:24 +10:00
psychedelicious
bbd7d7fc17 chore(ui): typegen 2024-08-24 11:55:50 +10:00
psychedelicious
6507a78182 feat(nodes): CanvasV2MaskAndCropInvocation can paste generated image back on source
This is needed for `Generate` mode.
2024-08-24 11:55:43 +10:00
psychedelicious
22f46517f4 fix(ui): extraneous entity preview updates 2024-08-24 11:28:05 +10:00
psychedelicious
45596e1f94 fix(ui): newly-added entities are selected 2024-08-24 11:14:58 +10:00
psychedelicious
6de0dbe854 feat(ui): add crosshair to color picker 2024-08-24 10:51:34 +10:00
psychedelicious
011827fa29 fix(ui): color picker ignores alpha 2024-08-24 10:16:27 +10:00
psychedelicious
fc6d244071 fix(ui): calculate renderable entities correctly in tool module 2024-08-24 10:10:21 +10:00
psychedelicious
cd3da886d6 feat(ui): better color picker 2024-08-24 10:10:04 +10:00
psychedelicious
c013c55d92 feat(ui): colored mask preview image 2024-08-24 08:54:20 +10:00
psychedelicious
cd3dd7db0d fix(ui): new rectangles don't trigger rerender 2024-08-23 23:24:16 +10:00
34 changed files with 558 additions and 238 deletions

View File

@@ -1032,7 +1032,11 @@ class CanvasV2MaskAndCropOutput(ImageOutput):
class CanvasV2MaskAndCropInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Handles Canvas V2 image output masking and cropping"""
image: ImageField = InputField(description="The image to apply the mask to")
source_image: ImageField | None = InputField(
default=None,
description="The source image onto which the masked generated image is pasted. If omitted, the masked generated image is returned with transparency.",
)
generated_image: ImageField = InputField(description="The image to apply the mask to")
mask: ImageField = InputField(description="The mask to apply")
mask_blur: int = InputField(default=0, ge=0, description="The amount to blur the mask by")
@@ -1046,33 +1050,25 @@ class CanvasV2MaskAndCropInvocation(BaseInvocation, WithMetadata, WithBoard):
return ImageOps.invert(mask.convert("L"))
def invoke(self, context: InvocationContext) -> CanvasV2MaskAndCropOutput:
image = context.images.get_pil(self.image.image_name)
mask = self._prepare_mask(context.images.get_pil(self.mask.image_name))
image.putalpha(mask)
if self.source_image:
generated_image = context.images.get_pil(self.generated_image.image_name)
source_image = context.images.get_pil(self.source_image.image_name)
source_image.paste(generated_image, (0, 0), mask)
image_dto = context.images.save(image=source_image)
else:
generated_image = context.images.get_pil(self.generated_image.image_name)
generated_image.putalpha(mask)
image_dto = context.images.save(image=generated_image)
# bbox = image.getbbox()
# image = image.crop(bbox)
image_dto = context.images.save(image=image)
return CanvasV2MaskAndCropOutput(
image=ImageField(image_name=image_dto.image_name),
offset_x=0,
offset_y=0,
width=image.width,
height=image.height,
width=image_dto.width,
height=image_dto.height,
)
# def invoke(self, context: InvocationContext) -> CanvasV2MaskAndCropOutput:
# image = context.images.get_pil(self.image.image_name)
# mask = self._prepare_mask(context.images.get_pil(self.mask.image_name))
# image.putalpha(mask)
# bbox = image.getbbox()
# image = image.crop(bbox)
# image_dto = context.images.save(image=image)
# return CanvasV2MaskAndCropOutput(
# image=ImageField(image_name=image_dto.image_name),
# offset_x=bbox[0],
# offset_y=bbox[1],
# width=image.width,
# height=image.height,
# )

View File

@@ -24,7 +24,7 @@
"build": "pnpm run lint && vite build",
"typegen": "node scripts/typegen.js",
"preview": "vite preview",
"lint:knip": "knip",
"lint:knip": "knip --tags=-knipignore",
"lint:dpdm": "dpdm --no-warning --no-tree --transform --exit-code circular:1 src/main.tsx",
"lint:eslint": "eslint --max-warnings=0 .",
"lint:prettier": "prettier --check .",

View File

@@ -1657,6 +1657,7 @@
"recalculateRects": "Recalculate Rects",
"clipToBbox": "Clip Strokes to Bbox",
"addLayer": "Add Layer",
"duplicate": "Duplicate",
"moveToFront": "Move to Front",
"moveToBack": "Move to Back",
"moveForward": "Move Forward",

View File

@@ -1,6 +1,5 @@
import { Button, ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useDefaultControlAdapter, useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
import {
controlLayerAdded,
inpaintMaskAdded,
@@ -15,23 +14,21 @@ import { PiPlusBold } from 'react-icons/pi';
export const CanvasAddEntityButtons = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const defaultControlAdapter = useDefaultControlAdapter();
const defaultIPAdapter = useDefaultIPAdapter();
const addInpaintMask = useCallback(() => {
dispatch(inpaintMaskAdded());
dispatch(inpaintMaskAdded({ isSelected: true }));
}, [dispatch]);
const addRegionalGuidance = useCallback(() => {
dispatch(rgAdded());
dispatch(rgAdded({ isSelected: true }));
}, [dispatch]);
const addRasterLayer = useCallback(() => {
dispatch(rasterLayerAdded({ isSelected: true }));
}, [dispatch]);
const addControlLayer = useCallback(() => {
dispatch(controlLayerAdded({ isSelected: true, overrides: { controlAdapter: defaultControlAdapter } }));
}, [defaultControlAdapter, dispatch]);
dispatch(controlLayerAdded({ isSelected: true }));
}, [dispatch]);
const addIPAdapter = useCallback(() => {
dispatch(ipaAdded({ ipAdapter: defaultIPAdapter }));
}, [defaultIPAdapter, dispatch]);
dispatch(ipaAdded({ isSelected: true }));
}, [dispatch]);
return (
<Flex flexDir="column" w="full" h="full" alignItems="center" justifyContent="center">

View File

@@ -1,6 +1,5 @@
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useDefaultControlAdapter, useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
import {
allEntitiesDeleted,
controlLayerAdded,
@@ -21,23 +20,21 @@ export const CanvasEntityListMenu = memo(() => {
const count = selectEntityCount(s);
return count > 0;
});
const defaultControlAdapter = useDefaultControlAdapter();
const defaultIPAdapter = useDefaultIPAdapter();
const addInpaintMask = useCallback(() => {
dispatch(inpaintMaskAdded());
dispatch(inpaintMaskAdded({ isSelected: true }));
}, [dispatch]);
const addRegionalGuidance = useCallback(() => {
dispatch(rgAdded());
dispatch(rgAdded({ isSelected: true }));
}, [dispatch]);
const addRasterLayer = useCallback(() => {
dispatch(rasterLayerAdded({ isSelected: true }));
}, [dispatch]);
const addControlLayer = useCallback(() => {
dispatch(controlLayerAdded({ isSelected: true, overrides: { controlAdapter: defaultControlAdapter } }));
}, [defaultControlAdapter, dispatch]);
dispatch(controlLayerAdded({ isSelected: true }));
}, [dispatch]);
const addIPAdapter = useCallback(() => {
dispatch(ipaAdded({ ipAdapter: defaultIPAdapter }));
}, [defaultIPAdapter, dispatch]);
dispatch(ipaAdded({ isSelected: true }));
}, [dispatch]);
const deleteAll = useCallback(() => {
dispatch(allEntitiesDeleted());
}, [dispatch]);

View File

@@ -1,6 +1,7 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { ControlLayerMenuItemsControlToRaster } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItemsControlToRaster';
@@ -17,6 +18,7 @@ export const ControlLayerMenuItems = memo(() => {
<MenuDivider />
<CanvasEntityMenuItemsArrange />
<MenuDivider />
<CanvasEntityMenuItemsDuplicate />
<CanvasEntityMenuItemsDelete />
</>
);

View File

@@ -1,6 +1,7 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { memo } from 'react';
export const IPAdapterMenuItems = memo(() => {
@@ -8,6 +9,7 @@ export const IPAdapterMenuItems = memo(() => {
<>
<CanvasEntityMenuItemsArrange />
<MenuDivider />
<CanvasEntityMenuItemsDuplicate />
<CanvasEntityMenuItemsDelete />
</>
);

View File

@@ -1,6 +1,7 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { memo } from 'react';
@@ -11,6 +12,7 @@ export const InpaintMaskMenuItems = memo(() => {
<MenuDivider />
<CanvasEntityMenuItemsArrange />
<MenuDivider />
<CanvasEntityMenuItemsDuplicate />
<CanvasEntityMenuItemsDelete />
</>
);

View File

@@ -1,6 +1,7 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { RasterLayerMenuItemsRasterToControl } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsRasterToControl';
@@ -15,6 +16,7 @@ export const RasterLayerMenuItems = memo(() => {
<MenuDivider />
<CanvasEntityMenuItemsArrange />
<MenuDivider />
<CanvasEntityMenuItemsDuplicate />
<CanvasEntityMenuItemsDelete />
</>
);

View File

@@ -1,8 +1,6 @@
import { Button, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
import { nanoid } from 'features/controlLayers/konva/util';
import {
rgIPAdapterAdded,
rgNegativePromptChanged,
@@ -20,7 +18,6 @@ type AddPromptButtonProps = {
export const RegionalGuidanceAddPromptsIPAdapterButtons = ({ id }: AddPromptButtonProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const defaultIPAdapter = useDefaultIPAdapter();
const selectValidActions = useMemo(
() =>
createMemoizedSelector(selectCanvasV2Slice, (canvasV2) => {
@@ -40,8 +37,8 @@ export const RegionalGuidanceAddPromptsIPAdapterButtons = ({ id }: AddPromptButt
dispatch(rgNegativePromptChanged({ id, prompt: '' }));
}, [dispatch, id]);
const addIPAdapter = useCallback(() => {
dispatch(rgIPAdapterAdded({ id, ipAdapter: { ...defaultIPAdapter, id: nanoid() } }));
}, [defaultIPAdapter, dispatch, id]);
dispatch(rgIPAdapterAdded({ id }));
}, [dispatch, id]);
return (
<Flex w="full" p={2} justifyContent="space-between">

View File

@@ -1,6 +1,7 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { RegionalGuidanceMenuItemsAddPromptsAndIPAdapter } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter';
import { RegionalGuidanceMenuItemsAutoNegative } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAutoNegative';
@@ -16,6 +17,7 @@ export const RegionalGuidanceMenuItems = memo(() => {
<MenuDivider />
<CanvasEntityMenuItemsArrange />
<MenuDivider />
<CanvasEntityMenuItemsDuplicate />
<CanvasEntityMenuItemsDelete />
</>
);

View File

@@ -2,8 +2,6 @@ import { MenuItem } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
import { nanoid } from 'features/controlLayers/konva/util';
import {
rgIPAdapterAdded,
rgNegativePromptChanged,
@@ -17,7 +15,6 @@ export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => {
const { id } = useEntityIdentifierContext();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const defaultIPAdapter = useDefaultIPAdapter();
const selectValidActions = useMemo(
() =>
createMemoizedSelector(selectCanvasV2Slice, (canvasV2) => {
@@ -37,8 +34,8 @@ export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => {
dispatch(rgNegativePromptChanged({ id: id, prompt: '' }));
}, [dispatch, id]);
const addIPAdapter = useCallback(() => {
dispatch(rgIPAdapterAdded({ id, ipAdapter: { ...defaultIPAdapter, id: nanoid() } }));
}, [defaultIPAdapter, dispatch, id]);
dispatch(rgIPAdapterAdded({ id }));
}, [dispatch, id]);
return (
<>

View File

@@ -0,0 +1,25 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { entityDuplicated } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyFill } from 'react-icons/pi';
export const CanvasEntityMenuItemsDuplicate = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const onClick = useCallback(() => {
dispatch(entityDuplicated({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem onClick={onClick} icon={<PiCopyFill />}>
{t('controlLayers.duplicate')}
</MenuItem>
);
});
CanvasEntityMenuItemsDuplicate.displayName = 'CanvasEntityMenuItemsDuplicate';

View File

@@ -1,13 +1,36 @@
import { Box, chakra, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { useEntityAdapter } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { memo, useEffect, useRef } from 'react';
import { selectCanvasV2Slice, selectEntity } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useSelector } from 'react-redux';
const ChakraCanvas = chakra.canvas;
const PADDING = 4;
export const CanvasEntityPreviewImage = memo(() => {
const entityIdentifier = useEntityIdentifierContext();
const adapter = useEntityAdapter();
const selectMaskColor = useMemo(
() =>
createSelector(selectCanvasV2Slice, (state) => {
const entity = selectEntity(state, entityIdentifier);
if (!entity) {
return null;
}
if (entity.type === 'inpaint_mask' || entity.type === 'regional_guidance') {
return rgbColorToString(entity.fill.color);
}
return null;
}),
[entityIdentifier]
);
const maskColor = useSelector(selectMaskColor);
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cache = useStore(adapter.renderer.$canvasCache);
@@ -26,8 +49,25 @@ export const CanvasEntityPreviewImage = memo(() => {
canvasRef.current.width = rect.width;
canvasRef.current.height = rect.height;
ctx.drawImage(canvas, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height);
}, [adapter.transformer, adapter.transformer.nodeRect, adapter.transformer.pixelRect, cache]);
const scale = containerRef.current.offsetWidth / rect.width;
const sx = rect.x;
const sy = rect.y;
const sWidth = rect.width;
const sHeight = rect.height;
const dx = PADDING / scale;
const dy = PADDING / scale;
const dWidth = rect.width - (PADDING * 2) / scale;
const dHeight = rect.height - (PADDING * 2) / scale;
ctx.drawImage(canvas, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
if (maskColor) {
ctx.fillStyle = maskColor;
ctx.globalCompositeOperation = 'source-in';
ctx.fillRect(0, 0, rect.width, rect.height);
}
}, [adapter.transformer, adapter.transformer.nodeRect, adapter.transformer.pixelRect, cache, maskColor]);
return (
<Flex

View File

@@ -27,6 +27,7 @@ export const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIden
return controlAdapter;
};
/** @knipignore */
export const useDefaultControlAdapter = (): ControlNetConfig | T2IAdapterConfig => {
const [modelConfigs] = useControlNetAndT2IAdapterModels();
@@ -47,6 +48,7 @@ export const useDefaultControlAdapter = (): ControlNetConfig | T2IAdapterConfig
return defaultControlAdapter;
};
/** @knipignore */
export const useDefaultIPAdapter = (): IPAdapterConfig => {
const [modelConfigs] = useIPAdapterModels();

View File

@@ -27,6 +27,7 @@ import type {
import { imageDTOToImageObject } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group';
import { debounce } from 'lodash-es';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
import { getImageDTO, uploadImage } from 'services/api/endpoints/images';
@@ -209,9 +210,7 @@ export class CanvasObjectRenderer {
this.log.trace('Caching object group');
this.konva.objectGroup.clearCache();
this.konva.objectGroup.cache({ pixelRatio: 1 });
if (!this.parent.transformer.isPendingRectCalculation) {
this.parent.renderer.updatePreviewCanvas();
}
this.parent.renderer.updatePreviewCanvas();
}
};
@@ -542,7 +541,10 @@ export class CanvasObjectRenderer {
return imageDTO;
};
updatePreviewCanvas = () => {
updatePreviewCanvas = debounce(() => {
if (this.parent.transformer.isPendingRectCalculation) {
return;
}
if (this.parent.transformer.pixelRect.width === 0 || this.parent.transformer.pixelRect.height === 0) {
return;
}
@@ -558,7 +560,7 @@ export class CanvasObjectRenderer {
};
this.$canvasCache.set({ rect, canvas });
}
};
}, 300);
cloneObjectGroup = (attrs?: GroupConfig): Konva.Group => {
const clone = this.konva.objectGroup.clone();

View File

@@ -25,6 +25,7 @@ import {
entitySelected,
eraserWidthChanged,
fillChanged,
selectAllRenderableEntities,
toolBufferChanged,
toolChanged,
} from 'features/controlLayers/store/canvasV2Slice';
@@ -43,6 +44,7 @@ import type {
EntityRectAddedPayload,
Rect,
RgbaColor,
RgbColor,
Tool,
} from 'features/controlLayers/store/types';
import { RGBA_BLACK } from 'features/controlLayers/store/types';
@@ -198,6 +200,17 @@ export class CanvasStateApiModule {
return null;
}
getRenderedEntityCount = () => {
const renderableEntities = selectAllRenderableEntities(this.getState());
let count = 0;
for (const entity of renderableEntities) {
if (entity.isEnabled) {
count++;
}
}
return count;
};
getSelectedEntity = () => {
const state = this.getState();
if (state.selectedEntityIdentifier) {
@@ -237,7 +250,7 @@ export class CanvasStateApiModule {
$currentFill: WritableAtom<RgbaColor> = atom();
$selectedEntity: WritableAtom<EntityStateAndAdapter | null> = atom();
$selectedEntityIdentifier: WritableAtom<CanvasEntityIdentifier | null> = atom();
$colorUnderCursor: WritableAtom<RgbaColor | null> = atom();
$colorUnderCursor: WritableAtom<RgbColor> = atom(RGBA_BLACK);
// Read-write state, ephemeral interaction state
$isDrawing = $isDrawing;

View File

@@ -1,12 +1,8 @@
import type { SerializableObject } from 'common/types';
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import { rgbaColorToString, rgbColorToString } from 'common/util/colorCodeTransformers';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule';
import {
BRUSH_BORDER_INNER_COLOR,
BRUSH_BORDER_OUTER_COLOR,
BRUSH_ERASER_BORDER_WIDTH,
} from 'features/controlLayers/konva/constants';
import { BRUSH_BORDER_INNER_COLOR, BRUSH_BORDER_OUTER_COLOR } from 'features/controlLayers/konva/constants';
import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util';
import type { Tool } from 'features/controlLayers/store/types';
import { isDrawableEntity } from 'features/controlLayers/store/types';
@@ -15,6 +11,12 @@ import type { Logger } from 'roarr';
export class CanvasToolModule {
readonly type = 'tool_preview';
static readonly COLOR_PICKER_RADIUS = 25;
static readonly COLOR_PICKER_THICKNESS = 15;
static readonly COLOR_PICKER_CROSSHAIR_SPACE = 5;
static readonly COLOR_PICKER_CROSSHAIR_INNER_THICKNESS = 1.5;
static readonly COLOR_PICKER_CROSSHAIR_OUTER_THICKNESS = 3;
static readonly COLOR_PICKER_CROSSHAIR_SIZE = 10;
id: string;
path: string[];
@@ -27,19 +29,29 @@ export class CanvasToolModule {
brush: {
group: Konva.Group;
fillCircle: Konva.Circle;
innerBorderCircle: Konva.Circle;
outerBorderCircle: Konva.Circle;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
eraser: {
group: Konva.Group;
fillCircle: Konva.Circle;
innerBorderCircle: Konva.Circle;
outerBorderCircle: Konva.Circle;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
colorPicker: {
group: Konva.Group;
fillCircle: Konva.Circle;
transparentCenterCircle: Konva.Circle;
newColor: Konva.Ring;
oldColor: Konva.Arc;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
crosshairNorthInner: Konva.Line;
crosshairNorthOuter: Konva.Line;
crosshairEastInner: Konva.Line;
crosshairEastOuter: Konva.Line;
crosshairSouthInner: Konva.Line;
crosshairSouthOuter: Konva.Line;
crosshairWestInner: Konva.Line;
crosshairWestOuter: Konva.Line;
};
};
@@ -63,19 +75,21 @@ export class CanvasToolModule {
listening: false,
strokeEnabled: false,
}),
innerBorderCircle: new Konva.Circle({
name: `${this.type}:brush_inner_border_circle`,
innerBorder: new Konva.Ring({
name: `${this.type}:brush_inner_border_ring`,
listening: false,
stroke: BRUSH_BORDER_INNER_COLOR,
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
strokeEnabled: true,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorderCircle: new Konva.Circle({
name: `${this.type}:brush_outer_border_circle`,
outerBorder: new Konva.Ring({
name: `${this.type}:brush_outer_border_ring`,
listening: false,
stroke: BRUSH_BORDER_OUTER_COLOR,
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
strokeEnabled: true,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
},
eraser: {
@@ -87,54 +101,110 @@ export class CanvasToolModule {
fill: 'white',
globalCompositeOperation: 'destination-out',
}),
innerBorderCircle: new Konva.Circle({
name: `${this.type}:eraser_inner_border_circle`,
innerBorder: new Konva.Ring({
name: `${this.type}:eraser_inner_border_ring`,
listening: false,
stroke: BRUSH_BORDER_INNER_COLOR,
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
strokeEnabled: true,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorderCircle: new Konva.Circle({
name: `${this.type}:eraser_outer_border_circle`,
listening: false,
stroke: BRUSH_BORDER_OUTER_COLOR,
strokeWidth: BRUSH_ERASER_BORDER_WIDTH,
strokeEnabled: true,
outerBorder: new Konva.Ring({
name: `${this.type}:eraser_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
},
colorPicker: {
group: new Konva.Group({ name: `${this.type}:color_picker_group`, listening: false }),
fillCircle: new Konva.Circle({
name: `${this.type}:color_picker_fill_circle`,
listening: false,
fill: '',
radius: 20,
strokeWidth: 1,
stroke: 'black',
strokeScaleEnabled: false,
}),
transparentCenterCircle: new Konva.Circle({
name: `${this.type}:color_picker_fill_circle`,
listening: false,
newColor: new Konva.Ring({
name: `${this.type}:color_picker_new_color_ring`,
innerRadius: 0,
outerRadius: 0,
strokeEnabled: false,
fill: 'white',
radius: 5,
globalCompositeOperation: 'destination-out',
}),
oldColor: new Konva.Arc({
name: `${this.type}:color_picker_old_color_arc`,
innerRadius: 0,
outerRadius: 0,
angle: 180,
strokeEnabled: false,
}),
innerBorder: new Konva.Ring({
name: `${this.type}:color_picker_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:color_picker_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: BRUSH_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
crosshairNorthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairNorthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
crosshairEastInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairEastOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
crosshairSouthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairSouthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
crosshairWestInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west1_line`,
stroke: BRUSH_BORDER_INNER_COLOR,
}),
crosshairWestOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west2_line`,
stroke: BRUSH_BORDER_OUTER_COLOR,
}),
},
};
this.konva.brush.group.add(this.konva.brush.fillCircle);
this.konva.brush.group.add(this.konva.brush.innerBorderCircle);
this.konva.brush.group.add(this.konva.brush.outerBorderCircle);
this.konva.brush.group.add(this.konva.brush.fillCircle, this.konva.brush.innerBorder, this.konva.brush.outerBorder);
this.konva.group.add(this.konva.brush.group);
this.konva.eraser.group.add(this.konva.eraser.fillCircle);
this.konva.eraser.group.add(this.konva.eraser.innerBorderCircle);
this.konva.eraser.group.add(this.konva.eraser.outerBorderCircle);
this.konva.eraser.group.add(
this.konva.eraser.fillCircle,
this.konva.eraser.innerBorder,
this.konva.eraser.outerBorder
);
this.konva.group.add(this.konva.eraser.group);
this.konva.colorPicker.group.add(this.konva.colorPicker.fillCircle);
this.konva.colorPicker.group.add(this.konva.colorPicker.transparentCenterCircle);
this.konva.colorPicker.group.add(
this.konva.colorPicker.newColor,
this.konva.colorPicker.oldColor,
this.konva.colorPicker.innerBorder,
this.konva.colorPicker.outerBorder,
this.konva.colorPicker.crosshairNorthOuter,
this.konva.colorPicker.crosshairNorthInner,
this.konva.colorPicker.crosshairEastOuter,
this.konva.colorPicker.crosshairEastInner,
this.konva.colorPicker.crosshairSouthOuter,
this.konva.colorPicker.crosshairSouthInner,
this.konva.colorPicker.crosshairWestOuter,
this.konva.colorPicker.crosshairWestInner
);
this.konva.group.add(this.konva.colorPicker.group);
this.subscriptions.add(
@@ -157,25 +227,6 @@ export class CanvasToolModule {
this.konva.group.destroy();
};
scaleTool = () => {
const toolState = this.manager.stateApi.getToolState();
const scale = this.manager.stage.getScale();
const brushRadius = toolState.brush.width / 2;
this.konva.brush.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
this.konva.brush.outerBorderCircle.setAttrs({
strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale,
radius: brushRadius + BRUSH_ERASER_BORDER_WIDTH / scale,
});
const eraserRadius = toolState.eraser.width / 2;
this.konva.eraser.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
this.konva.eraser.outerBorderCircle.setAttrs({
strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale,
radius: eraserRadius + BRUSH_ERASER_BORDER_WIDTH / scale,
});
};
setToolVisibility = (tool: Tool) => {
this.konva.brush.group.visible(tool === 'brush');
this.konva.eraser.group.visible(tool === 'eraser');
@@ -184,13 +235,11 @@ export class CanvasToolModule {
render() {
const stage = this.manager.stage;
const renderedEntityCount: number = 1; // TODO(psyche): this.manager should be renderable entity count
const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount();
const toolState = this.manager.stateApi.getToolState();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const cursorPos = this.manager.stateApi.$lastCursorPos.get();
const isDrawing = this.manager.stateApi.$isDrawing.get();
const isMouseDown = this.manager.stateApi.$isMouseDown.get();
const colorUnderCursor = this.manager.stateApi.$colorUnderCursor.get();
const tool = toolState.selected;
@@ -238,32 +287,38 @@ export class CanvasToolModule {
if (cursorPos && tool === 'brush') {
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const scale = stage.getScale();
// Update the fill circle
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.brush.width / 2;
// The circle is scaled
this.konva.brush.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: isDrawing ? '' : rgbaColorToString(brushPreviewFill),
fill: rgbaColorToString(brushPreviewFill),
});
// Update the inner border of the brush preview
this.konva.brush.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius });
// Update the outer border of the brush preview
this.konva.brush.outerBorderCircle.setAttrs({
// But the borders are in screen-pixels
this.konva.brush.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.brush.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
} else if (cursorPos && tool === 'eraser') {
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const scale = stage.getScale();
// Update the fill circle
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
const radius = toolState.eraser.width / 2;
// The circle is scaled
this.konva.eraser.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
@@ -271,28 +326,97 @@ export class CanvasToolModule {
fill: 'white',
});
// Update the inner border of the eraser preview
this.konva.eraser.innerBorderCircle.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius });
// But the borders are in screen-pixels
this.konva.eraser.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.eraser.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
} else if (cursorPos && tool === 'colorPicker') {
const colorUnderCursor = this.manager.stateApi.$colorUnderCursor.get();
const colorPickerInnerRadius = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_RADIUS);
const colorPickerOuterRadius = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_RADIUS + CanvasToolModule.COLOR_PICKER_THICKNESS
);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
// Update the outer border of the eraser preview
this.konva.eraser.outerBorderCircle.setAttrs({
this.konva.colorPicker.newColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
fill: rgbColorToString(colorUnderCursor),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
} else if (cursorPos && colorUnderCursor) {
this.konva.colorPicker.fillCircle.setAttrs({
this.konva.colorPicker.oldColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbaColorToString(colorUnderCursor),
fill: rgbColorToString(toolState.fill),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.colorPicker.transparentCenterCircle.setAttrs({
this.konva.colorPicker.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius,
outerRadius: colorPickerOuterRadius + onePixel,
});
this.konva.colorPicker.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius + onePixel,
outerRadius: colorPickerOuterRadius + twoPixels,
});
const size = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SIZE);
const space = this.manager.stage.getScaledPixels(CanvasToolModule.COLOR_PICKER_CROSSHAIR_SPACE);
const innerThickness = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_CROSSHAIR_INNER_THICKNESS
);
const outerThickness = this.manager.stage.getScaledPixels(
CanvasToolModule.COLOR_PICKER_CROSSHAIR_OUTER_THICKNESS
);
this.konva.colorPicker.crosshairNorthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairNorthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.colorPicker.crosshairEastOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairEastInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.colorPicker.crosshairSouthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairSouthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.colorPicker.crosshairWestOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
this.konva.colorPicker.crosshairWestInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
}
this.scaleTool();
this.setToolVisibility(tool);
}
}

View File

@@ -602,7 +602,6 @@ export class CanvasTransformer {
if (this.isPendingRectCalculation) {
this.syncInteractionState();
this.parent.renderer.updatePreviewCanvas();
return;
}
@@ -613,20 +612,19 @@ export class CanvasTransformer {
// The layer is fully transparent but has objects - reset it
this.manager.stateApi.resetEntity({ entityIdentifier: this.parent.getEntityIdentifier() });
this.syncInteractionState();
this.parent.renderer.updatePreviewCanvas();
return;
} else {
this.syncInteractionState();
this.update(this.parent.state.position, this.pixelRect);
const groupAttrs: Partial<GroupConfig> = {
x: this.parent.state.position.x + this.pixelRect.x,
y: this.parent.state.position.y + this.pixelRect.y,
offsetX: this.pixelRect.x,
offsetY: this.pixelRect.y,
};
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
}
this.syncInteractionState();
this.update(this.parent.state.position, this.pixelRect);
const groupAttrs: Partial<GroupConfig> = {
x: this.parent.state.position.x + this.pixelRect.x,
y: this.parent.state.position.y + this.pixelRect.y,
offsetX: this.pixelRect.x,
offsetY: this.pixelRect.y,
};
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
this.parent.renderer.updatePreviewCanvas();
};
@@ -649,8 +647,8 @@ export class CanvasTransformer {
if (!this.parent.renderer.needsPixelBbox()) {
this.nodeRect = { ...rect };
this.pixelRect = { ...rect };
this.isPendingRectCalculation = false;
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect }, 'Got bbox from client rect');
this.isPendingRectCalculation = false;
this.updateBbox();
return;
}
@@ -674,8 +672,8 @@ export class CanvasTransformer {
this.nodeRect = getEmptyRect();
this.pixelRect = getEmptyRect();
}
this.isPendingRectCalculation = false;
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect, extents }, `Got bbox from worker`);
this.isPendingRectCalculation = false;
this.updateBbox();
}
);

View File

@@ -15,11 +15,6 @@ export const BRUSH_BORDER_INNER_COLOR = 'rgba(0,0,0,1)';
*/
export const BRUSH_BORDER_OUTER_COLOR = 'rgba(255,255,255,0.8)';
/**
* The border width for the brush preview.
*/
export const BRUSH_ERASER_BORDER_WIDTH = 1;
/**
* The target spacing of individual points of brush strokes, as a percentage of the brush size.
*/

View File

@@ -12,7 +12,7 @@ import type {
CanvasRegionalGuidanceState,
CanvasV2State,
Coordinate,
RgbaColor,
RgbColor,
Tool,
} from 'features/controlLayers/store/types';
import type Konva from 'konva';
@@ -115,7 +115,7 @@ const getLastPointOfLastLineOfEntity = (
return { x, y };
};
const getColorUnderCursor = (stage: Konva.Stage): RgbaColor | null => {
const getColorUnderCursor = (stage: Konva.Stage): RgbColor | null => {
const pos = stage.getPointerPosition();
if (!pos) {
return null;
@@ -126,12 +126,12 @@ const getColorUnderCursor = (stage: Konva.Stage): RgbaColor | null => {
if (!ctx) {
return null;
}
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
if (r === undefined || g === undefined || b === undefined || a === undefined) {
const [r, g, b, _a] = ctx.getImageData(0, 0, 1, 1).data;
if (r === undefined || g === undefined || b === undefined) {
return null;
}
return { r, g, b, a };
return { r, g, b };
};
export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
@@ -195,9 +195,11 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (toolState.selected === 'colorPicker') {
const color = getColorUnderCursor(stage);
manager.stateApi.$colorUnderCursor.set(color);
if (color) {
manager.stateApi.setFill(color);
manager.stateApi.$colorUnderCursor.set(color);
}
if (color) {
manager.stateApi.setFill({ ...color, a: 1 });
}
manager.preview.tool.render();
} else {
@@ -345,7 +347,9 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (toolState.selected === 'colorPicker') {
const color = getColorUnderCursor(stage);
manager.stateApi.$colorUnderCursor.set(color);
if (color) {
manager.stateApi.$colorUnderCursor.set(color);
}
} else {
const isDrawable = selectedEntity?.state.isEnabled;
if (pos && isDrawable && !$spaceKey.get() && getIsPrimaryMouseDown(e)) {

View File

@@ -1,4 +1,4 @@
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import type { CanvasEntityIdentifier, Coordinate, Rect } from 'features/controlLayers/store/types';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
@@ -335,7 +335,7 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
*/
export const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
export function getPrefixedId(prefix: string): string {
export function getPrefixedId(prefix: CanvasEntityIdentifier['type'] | (string & Record<never, never>)): string {
return `${prefix}:${nanoid()}`;
}

View File

@@ -3,6 +3,7 @@ import { createAction, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/util/arrayUtils';
import { deepClone } from 'common/util/deepClone';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { bboxReducers } from 'features/controlLayers/store/bboxReducers';
import { compositingReducers } from 'features/controlLayers/store/compositingReducers';
import { controlLayersReducers } from 'features/controlLayers/store/controlLayersReducers';
@@ -24,8 +25,12 @@ import { atom } from 'nanostores';
import { assert } from 'tsafe';
import type {
CanvasControlLayerState,
CanvasEntityIdentifier,
CanvasEntityState,
CanvasInpaintMaskState,
CanvasRasterLayerState,
CanvasRegionalGuidanceState,
CanvasV2State,
Coordinate,
EntityBrushLineAddedPayload,
@@ -181,6 +186,17 @@ function selectAllEntities(state: CanvasV2State): CanvasEntityState[] {
];
}
export function selectAllRenderableEntities(
state: CanvasV2State
): (CanvasRasterLayerState | CanvasControlLayerState | CanvasInpaintMaskState | CanvasRegionalGuidanceState)[] {
return [
...state.rasterLayers.entities,
...state.controlLayers.entities,
...state.inpaintMasks.entities,
...state.regions.entities,
];
}
export const canvasV2Slice = createSlice({
name: 'canvasV2',
initialState,
@@ -222,6 +238,42 @@ export const canvasV2Slice = createSlice({
assert(false, 'Not implemented');
}
},
entityDuplicated: (state, action: PayloadAction<EntityIdentifierPayload>) => {
const { entityIdentifier } = action.payload;
const entity = selectEntity(state, entityIdentifier);
if (!entity) {
return;
}
const newEntity = deepClone(entity);
if (newEntity.name) {
newEntity.name = `${newEntity.name} (Copy)`;
}
switch (newEntity.type) {
case 'raster_layer':
newEntity.id = getPrefixedId('raster_layer');
state.rasterLayers.entities.push(newEntity);
break;
case 'control_layer':
newEntity.id = getPrefixedId('control_layer');
state.controlLayers.entities.push(newEntity);
break;
case 'regional_guidance':
newEntity.id = getPrefixedId('regional_guidance');
state.regions.entities.push(newEntity);
break;
case 'ip_adapter':
newEntity.id = getPrefixedId('ip_adapter');
state.ipAdapters.entities.push(newEntity);
break;
case 'inpaint_mask':
newEntity.id = getPrefixedId('inpaint_mask');
state.inpaintMasks.entities.push(newEntity);
break;
}
state.selectedEntityIdentifier = getEntityIdentifier(newEntity);
},
entityIsEnabledToggled: (state, action: PayloadAction<EntityIdentifierPayload>) => {
const { entityIdentifier } = action.payload;
const entity = selectEntity(state, entityIdentifier);
@@ -266,6 +318,8 @@ export const canvasV2Slice = createSlice({
assert(false, `Cannot add a brush line to a non-drawable entity of type ${entity.type}`);
}
// TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not
// re-render it (reference equality check). I don't like this behaviour.
entity.objects.push({ ...brushLine, points: simplifyFlatNumbersArray(brushLine.points) });
},
entityEraserLineAdded: (state, action: PayloadAction<EntityEraserLineAddedPayload>) => {
@@ -279,6 +333,8 @@ export const canvasV2Slice = createSlice({
assert(false, `Cannot add a eraser line to a non-drawable entity of type ${entity.type}`);
}
// TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not
// re-render it (reference equality check). I don't like this behaviour.
entity.objects.push({ ...eraserLine, points: simplifyFlatNumbersArray(eraserLine.points) });
},
entityRectAdded: (state, action: PayloadAction<EntityRectAddedPayload>) => {
@@ -292,7 +348,9 @@ export const canvasV2Slice = createSlice({
assert(false, `Cannot add a rect to a non-drawable entity of type ${entity.type}`);
}
entity.objects.push(rect);
// TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not
// re-render it (reference equality check). I don't like this behaviour.
entity.objects.push({ ...rect });
},
entityDeleted: (state, action: PayloadAction<EntityIdentifierPayload>) => {
const { entityIdentifier } = action.payload;
@@ -439,6 +497,7 @@ export const {
entityReset,
entityIsEnabledToggled,
entityMoved,
entityDuplicated,
entityRasterized,
entityBrushLineAdded,
entityEraserLineAdded,

View File

@@ -14,7 +14,7 @@ import type {
ControlNetConfig,
T2IAdapterConfig,
} from './types';
import { initialControlNet } from './types';
import { getEntityIdentifier, initialControlNet } from './types';
const selectControlLayerEntity = (state: CanvasV2State, id: string) =>
state.controlLayers.entities.find((entity) => entity.id === id);
@@ -31,7 +31,7 @@ export const controlLayersReducers = {
action: PayloadAction<{ id: string; overrides?: Partial<CanvasControlLayerState>; isSelected?: boolean }>
) => {
const { id, overrides, isSelected } = action.payload;
const layer: CanvasControlLayerState = {
const entity: CanvasControlLayerState = {
id,
name: null,
type: 'control_layer',
@@ -42,10 +42,10 @@ export const controlLayersReducers = {
position: { x: 0, y: 0 },
controlAdapter: deepClone(initialControlNet),
};
merge(layer, overrides);
state.controlLayers.entities.push(layer);
merge(entity, overrides);
state.controlLayers.entities.push(entity);
if (isSelected) {
state.selectedEntityIdentifier = { type: 'control_layer', id };
state.selectedEntityIdentifier = getEntityIdentifier(entity);
}
},
prepare: (payload: { overrides?: Partial<CanvasControlLayerState>; isSelected?: boolean }) => ({

View File

@@ -1,11 +1,12 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type {
CanvasInpaintMaskState,
CanvasV2State,
EntityIdentifierPayload,
FillStyle,
RgbColor,
import {
type CanvasInpaintMaskState,
type CanvasV2State,
type EntityIdentifierPayload,
type FillStyle,
getEntityIdentifier,
type RgbColor,
} from 'features/controlLayers/store/types';
import { merge } from 'lodash-es';
import { assert } from 'tsafe';
@@ -41,7 +42,7 @@ export const inpaintMaskReducers = {
merge(entity, overrides);
state.inpaintMasks.entities.push(entity);
if (isSelected) {
state.selectedEntityIdentifier = { type: 'inpaint_mask', id };
state.selectedEntityIdentifier = getEntityIdentifier(entity);
}
},
prepare: (payload?: { overrides?: Partial<CanvasInpaintMaskState>; isSelected?: boolean }) => ({

View File

@@ -1,11 +1,14 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import { deepClone } from 'common/util/deepClone';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { zModelIdentifierField } from 'features/nodes/types/common';
import { merge } from 'lodash-es';
import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';
import type { CanvasIPAdapterState, CanvasV2State, CLIPVisionModelV2, IPAdapterConfig, IPMethodV2 } from './types';
import { imageDTOToImageWithDims } from './types';
import type { CanvasIPAdapterState, CanvasV2State, CLIPVisionModelV2, IPMethodV2 } from './types';
import { getEntityIdentifier, imageDTOToImageWithDims, initialIPAdapter } from './types';
const selectIPAdapterEntity = (state: CanvasV2State, id: string) =>
state.ipAdapters.entities.find((ipa) => ipa.id === id);
@@ -17,19 +20,27 @@ export const selectIPAdapterEntityOrThrow = (state: CanvasV2State, id: string) =
export const ipAdaptersReducers = {
ipaAdded: {
reducer: (state, action: PayloadAction<{ id: string; ipAdapter: IPAdapterConfig }>) => {
const { id, ipAdapter } = action.payload;
const layer: CanvasIPAdapterState = {
reducer: (
state,
action: PayloadAction<{ id: string; overrides?: Partial<CanvasIPAdapterState>; isSelected?: boolean }>
) => {
const { id, overrides, isSelected } = action.payload;
const entity: CanvasIPAdapterState = {
id,
type: 'ip_adapter',
name: null,
isEnabled: true,
ipAdapter,
ipAdapter: deepClone(initialIPAdapter),
};
state.ipAdapters.entities.push(layer);
state.selectedEntityIdentifier = { type: 'ip_adapter', id };
merge(entity, overrides);
state.ipAdapters.entities.push(entity);
if (isSelected) {
state.selectedEntityIdentifier = getEntityIdentifier(entity);
}
},
prepare: (payload: { ipAdapter: IPAdapterConfig }) => ({ payload: { id: uuidv4(), ...payload } }),
prepare: (payload?: { overrides?: Partial<CanvasIPAdapterState>; isSelected?: boolean }) => ({
payload: { ...payload, id: getPrefixedId('ip_adapter') },
}),
},
ipaRecalled: (state, action: PayloadAction<{ data: CanvasIPAdapterState }>) => {
const { data } = action.payload;

View File

@@ -4,7 +4,7 @@ import { getPrefixedId } from 'features/controlLayers/konva/util';
import { merge } from 'lodash-es';
import type { CanvasControlLayerState, CanvasRasterLayerState, CanvasV2State } from './types';
import { initialControlNet } from './types';
import { getEntityIdentifier, initialControlNet } from './types';
const selectRasterLayerEntity = (state: CanvasV2State, id: string) =>
state.rasterLayers.entities.find((layer) => layer.id === id);
@@ -16,7 +16,7 @@ export const rasterLayersReducers = {
action: PayloadAction<{ id: string; overrides?: Partial<CanvasRasterLayerState>; isSelected?: boolean }>
) => {
const { id, overrides, isSelected } = action.payload;
const layer: CanvasRasterLayerState = {
const entity: CanvasRasterLayerState = {
id,
name: null,
type: 'raster_layer',
@@ -25,10 +25,10 @@ export const rasterLayersReducers = {
opacity: 1,
position: { x: 0, y: 0 },
};
merge(layer, overrides);
state.rasterLayers.entities.push(layer);
merge(entity, overrides);
state.rasterLayers.entities.push(entity);
if (isSelected) {
state.selectedEntityIdentifier = { type: 'raster_layer', id };
state.selectedEntityIdentifier = getEntityIdentifier(entity);
}
},
prepare: (payload: { overrides?: Partial<CanvasRasterLayerState>; isSelected?: boolean }) => ({

View File

@@ -1,4 +1,5 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import { deepClone } from 'common/util/deepClone';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type {
CanvasV2State,
@@ -8,9 +9,9 @@ import type {
RegionalGuidanceIPAdapterConfig,
RgbColor,
} from 'features/controlLayers/store/types';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/types';
import { getEntityIdentifier, imageDTOToImageWithDims, initialIPAdapter } from 'features/controlLayers/store/types';
import { zModelIdentifierField } from 'features/nodes/types/common';
import { isEqual } from 'lodash-es';
import { isEqual, merge } from 'lodash-es';
import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
@@ -56,9 +57,12 @@ const getRGMaskFill = (state: CanvasV2State): RgbColor => {
export const regionsReducers = {
rgAdded: {
reducer: (state, action: PayloadAction<{ id: string }>) => {
const { id } = action.payload;
const rg: CanvasRegionalGuidanceState = {
reducer: (
state,
action: PayloadAction<{ id: string; overrides?: Partial<CanvasRegionalGuidanceState>; isSelected?: boolean }>
) => {
const { id, overrides, isSelected } = action.payload;
const entity: CanvasRegionalGuidanceState = {
id,
name: null,
type: 'regional_guidance',
@@ -75,10 +79,15 @@ export const regionsReducers = {
negativePrompt: null,
ipAdapters: [],
};
state.regions.entities.push(rg);
state.selectedEntityIdentifier = { type: 'regional_guidance', id };
merge(entity, overrides);
state.regions.entities.push(entity);
if (isSelected) {
state.selectedEntityIdentifier = getEntityIdentifier(entity);
}
},
prepare: () => ({ payload: { id: getPrefixedId('regional_guidance') } }),
prepare: (payload?: { overrides?: Partial<CanvasRegionalGuidanceState>; isSelected?: boolean }) => ({
payload: { ...payload, id: getPrefixedId('regional_guidance') },
}),
},
rgRecalled: (state, action: PayloadAction<{ data: CanvasRegionalGuidanceState }>) => {
const { data } = action.payload;
@@ -126,13 +135,23 @@ export const regionsReducers = {
}
rg.autoNegative = !rg.autoNegative;
},
rgIPAdapterAdded: (state, action: PayloadAction<{ id: string; ipAdapter: RegionalGuidanceIPAdapterConfig }>) => {
const { id, ipAdapter } = action.payload;
const entity = selectRegionalGuidanceEntity(state, id);
if (!entity) {
return;
}
entity.ipAdapters.push(ipAdapter);
rgIPAdapterAdded: {
reducer: (
state,
action: PayloadAction<{ id: string; ipAdapterId: string; overrides?: Partial<RegionalGuidanceIPAdapterConfig> }>
) => {
const { id, overrides, ipAdapterId } = action.payload;
const entity = selectRegionalGuidanceEntity(state, id);
if (!entity) {
return;
}
const ipAdapter = { ...deepClone(initialIPAdapter), id: ipAdapterId };
merge(ipAdapter, overrides);
entity.ipAdapters.push(ipAdapter);
},
prepare: (payload: { id: string; overrides?: Partial<RegionalGuidanceIPAdapterConfig> }) => ({
payload: { ...payload, ipAdapterId: getPrefixedId('regional_guidance_ip_adapter') },
}),
},
rgIPAdapterDeleted: (state, action: PayloadAction<{ id: string; ipAdapterId: string }>) => {
const { id, ipAdapterId } = action.payload;

View File

@@ -1,3 +1,4 @@
import type { RootState } from 'app/store/store';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasV2State, Dimensions } from 'features/controlLayers/store/types';
@@ -7,6 +8,7 @@ import { isEqual } from 'lodash-es';
import type { Invocation } from 'services/api/types';
export const addInpaint = async (
state: RootState,
g: Graph,
manager: CanvasManager,
l2i: Invocation<'l2i'>,
@@ -22,6 +24,7 @@ export const addInpaint = async (
): Promise<Invocation<'canvas_v2_mask_and_crop'>> => {
denoise.denoising_start = denoising_start;
const mode = state.canvasV2.session.mode;
const initialImage = await manager.compositor.getCompositeRasterLayerImageDTO(bbox.rect);
const maskImage = await manager.compositor.getCompositeInpaintMaskImageDTO(bbox.rect);
@@ -87,9 +90,13 @@ export const addInpaint = async (
g.addEdge(createGradientMask, 'expanded_mask_area', resizeMaskToOriginalSize, 'image');
// Finally, paste the generated masked image back onto the original image
g.addEdge(resizeImageToOriginalSize, 'image', canvasPasteBack, 'image');
g.addEdge(resizeImageToOriginalSize, 'image', canvasPasteBack, 'generated_image');
g.addEdge(resizeMaskToOriginalSize, 'image', canvasPasteBack, 'mask');
if (mode === 'generate') {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}
return canvasPasteBack;
} else {
// No scale before processing, much simpler
@@ -114,6 +121,7 @@ export const addInpaint = async (
type: 'canvas_v2_mask_and_crop',
mask_blur: compositing.maskBlur,
});
g.addEdge(alphaToMask, 'image', createGradientMask, 'mask');
g.addEdge(i2l, 'latents', denoise, 'latents');
g.addEdge(vaeSource, 'vae', i2l, 'vae');
@@ -122,7 +130,11 @@ export const addInpaint = async (
g.addEdge(createGradientMask, 'denoise_mask', denoise, 'denoise_mask');
g.addEdge(createGradientMask, 'expanded_mask_area', canvasPasteBack, 'mask');
g.addEdge(l2i, 'image', canvasPasteBack, 'image');
g.addEdge(l2i, 'image', canvasPasteBack, 'generated_image');
if (mode === 'generate') {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}
return canvasPasteBack;
}

View File

@@ -1,3 +1,4 @@
import type { RootState } from 'app/store/store';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasV2State, Dimensions } from 'features/controlLayers/store/types';
@@ -8,6 +9,7 @@ import { isEqual } from 'lodash-es';
import type { Invocation } from 'services/api/types';
export const addOutpaint = async (
state: RootState,
g: Graph,
manager: CanvasManager,
l2i: Invocation<'l2i'>,
@@ -23,6 +25,7 @@ export const addOutpaint = async (
): Promise<Invocation<'canvas_v2_mask_and_crop'>> => {
denoise.denoising_start = denoising_start;
const mode = state.canvasV2.session.mode;
const initialImage = await manager.compositor.getCompositeRasterLayerImageDTO(bbox.rect);
const maskImage = await manager.compositor.getCompositeInpaintMaskImageDTO(bbox.rect);
const infill = getInfill(g, compositing);
@@ -111,9 +114,13 @@ export const addOutpaint = async (
g.addEdge(createGradientMask, 'expanded_mask_area', resizeOutputMaskToOriginalSize, 'image');
// Finally, paste the generated masked image back onto the original image
g.addEdge(resizeOutputImageToOriginalSize, 'image', canvasPasteBack, 'image');
g.addEdge(resizeOutputImageToOriginalSize, 'image', canvasPasteBack, 'generated_image');
g.addEdge(resizeOutputMaskToOriginalSize, 'image', canvasPasteBack, 'mask');
if (mode === 'generate') {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}
return canvasPasteBack;
} else {
infill.image = { image_name: initialImage.image_name };
@@ -158,7 +165,11 @@ export const addOutpaint = async (
g.addEdge(modelLoader, 'unet', createGradientMask, 'unet');
g.addEdge(createGradientMask, 'denoise_mask', denoise, 'denoise_mask');
g.addEdge(createGradientMask, 'expanded_mask_area', canvasPasteBack, 'mask');
g.addEdge(l2i, 'image', canvasPasteBack, 'image');
g.addEdge(l2i, 'image', canvasPasteBack, 'generated_image');
if (mode === 'generate') {
canvasPasteBack.source_image = { image_name: initialImage.image_name };
}
return canvasPasteBack;
}

View File

@@ -173,6 +173,7 @@ export const buildSD1Graph = async (
} else if (generationMode === 'inpaint') {
const { compositing } = state.canvasV2;
canvasOutput = await addInpaint(
state,
g,
manager,
l2i,
@@ -189,6 +190,7 @@ export const buildSD1Graph = async (
} else if (generationMode === 'outpaint') {
const { compositing } = state.canvasV2;
canvasOutput = await addOutpaint(
state,
g,
manager,
l2i,

View File

@@ -176,6 +176,7 @@ export const buildSDXLGraph = async (
} else if (generationMode === 'inpaint') {
const { compositing } = state.canvasV2;
canvasOutput = await addInpaint(
state,
g,
manager,
l2i,
@@ -192,6 +193,7 @@ export const buildSDXLGraph = async (
} else if (generationMode === 'outpaint') {
const { compositing } = state.canvasV2;
canvasOutput = await addOutpaint(
state,
g,
manager,
l2i,

View File

@@ -3061,11 +3061,16 @@ export type components = {
* @default true
*/
use_cache?: boolean;
/**
* @description The source image onto which the masked generated image is pasted. If omitted, the masked generated image is returned with transparency.
* @default null
*/
source_image?: components["schemas"]["ImageField"] | null;
/**
* @description The image to apply the mask to
* @default null
*/
image?: components["schemas"]["ImageField"];
generated_image?: components["schemas"]["ImageField"];
/**
* @description The mask to apply
* @default null

View File

@@ -1 +1 @@
__version__ = "4.2.9.dev20240823"
__version__ = "4.2.9.dev20240824"