diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index dd747e72f3..ebb313cd70 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1665,6 +1665,8 @@ "addIPAdapter": "Add $t(common.ipAdapter)", "regionalGuidance": "Regional Guidance", "regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)", + "raster": "Raster", + "rasterLayer": "$t(controlLayers.raster) $t(unifiedCanvas.layer)", "opacity": "Opacity", "globalControlAdapter": "Global $t(controlnet.controlAdapter_one)", "globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)", diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts index 7ea5311585..77fe9be9b2 100644 --- a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts +++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts @@ -29,6 +29,7 @@ const LAYER_TYPE_TO_TKEY: Record = { control_adapter_layer: 'controlLayers.globalControlAdapter', ip_adapter_layer: 'controlLayers.globalIPAdapter', regional_guidance_layer: 'controlLayers.regionalGuidance', + raster_layer: 'controlLayers.raster', }; const createSelector = (templates: Templates) => diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx index c7a49da8c7..fdfa70ae2c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx @@ -1,7 +1,7 @@ import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useAddCALayer, useAddIILayer, useAddIPALayer } from 'features/controlLayers/hooks/addLayerHooks'; -import { rgLayerAdded } from 'features/controlLayers/store/controlLayersSlice'; +import { rasterLayerAdded, rgLayerAdded } from 'features/controlLayers/store/controlLayersSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; @@ -15,6 +15,9 @@ export const AddLayerButton = memo(() => { const addRGLayer = useCallback(() => { dispatch(rgLayerAdded()); }, [dispatch]); + const addRasterLayer = useCallback(() => { + dispatch(rasterLayerAdded()); + }, [dispatch]); return ( @@ -30,6 +33,9 @@ export const AddLayerButton = memo(() => { } onClick={addRGLayer}> {t('controlLayers.regionalGuidanceLayer')} + } onClick={addRasterLayer}> + {t('controlLayers.rasterLayer')} + } onClick={addCALayer} isDisabled={isAddCALayerDisabled}> {t('controlLayers.globalControlAdapterLayer')} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx index 353f8e0307..e272282ea8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CALayer/CALayerOpacity.tsx @@ -14,7 +14,7 @@ import { } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { stopPropagation } from 'common/util/stopPropagation'; -import { useLayerOpacity } from 'features/controlLayers/hooks/layerStateHooks'; +import { useCALayerOpacity } from 'features/controlLayers/hooks/layerStateHooks'; import { caLayerIsFilterEnabledChanged, caLayerOpacityChanged } from 'features/controlLayers/store/controlLayersSlice'; import type { ChangeEvent } from 'react'; import { memo, useCallback } from 'react'; @@ -31,7 +31,7 @@ const formatPct = (v: number | string) => `${v} %`; const CALayerOpacity = ({ layerId }: Props) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const { opacity, isFilterEnabled } = useLayerOpacity(layerId); + const { opacity, isFilterEnabled } = useCALayerOpacity(layerId); const onChangeOpacity = useCallback( (v: number) => { dispatch(caLayerOpacityChanged({ layerId, opacity: v / 100 })); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx index d3ddc07139..4f17870e68 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx @@ -9,6 +9,7 @@ import { CALayer } from 'features/controlLayers/components/CALayer/CALayer'; import { DeleteAllLayersButton } from 'features/controlLayers/components/DeleteAllLayersButton'; import { IILayer } from 'features/controlLayers/components/IILayer/IILayer'; import { IPALayer } from 'features/controlLayers/components/IPALayer/IPALayer'; +import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer'; import { RGLayer } from 'features/controlLayers/components/RGLayer/RGLayer'; import { isRenderableLayer, selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice'; import type { Layer } from 'features/controlLayers/store/types'; @@ -64,6 +65,9 @@ const LayerWrapper = memo(({ id, type }: LayerWrapperProps) => { if (type === 'initial_image_layer') { return ; } + if (type === 'raster_layer') { + return ; + } }); LayerWrapper.displayName = 'LayerWrapper'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx index 12074d12b8..0a3b52a3ff 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerMenu.tsx @@ -5,7 +5,7 @@ import { LayerMenuArrangeActions } from 'features/controlLayers/components/Layer import { LayerMenuRGActions } from 'features/controlLayers/components/LayerCommon/LayerMenuRGActions'; import { useLayerType } from 'features/controlLayers/hooks/layerStateHooks'; import { layerDeleted, layerReset } from 'features/controlLayers/store/controlLayersSlice'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiDotsThreeVerticalBold, PiTrashSimpleBold } from 'react-icons/pi'; @@ -21,6 +21,15 @@ export const LayerMenu = memo(({ layerId }: Props) => { const deleteLayer = useCallback(() => { dispatch(layerDeleted(layerId)); }, [dispatch, layerId]); + const shouldShowArrangeActions = useMemo(() => { + return ( + layerType === 'regional_guidance_layer' || + layerType === 'control_adapter_layer' || + layerType === 'initial_image_layer' || + layerType === 'raster_layer' + ); + }, [layerType]); + return ( { )} - {(layerType === 'regional_guidance_layer' || - layerType === 'control_adapter_layer' || - layerType === 'initial_image_layer') && ( + {shouldShowArrangeActions && ( <> diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx index b29c3753fc..a74729d91b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/LayerCommon/LayerTitle.tsx @@ -18,6 +18,8 @@ export const LayerTitle = memo(({ type }: Props) => { return t('controlLayers.globalIPAdapter'); } else if (type === 'initial_image_layer') { return t('controlLayers.globalInitialImage'); + } else if (type === 'raster_layer') { + return t('controlLayers.rasterLayer'); } }, [t, type]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx new file mode 100644 index 0000000000..80a32509b4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -0,0 +1,44 @@ +import { Flex, Spacer, useDisclosure } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { LayerDeleteButton } from 'features/controlLayers/components/LayerCommon/LayerDeleteButton'; +import { LayerMenu } from 'features/controlLayers/components/LayerCommon/LayerMenu'; +import { LayerTitle } from 'features/controlLayers/components/LayerCommon/LayerTitle'; +import { LayerIsEnabledToggle } from 'features/controlLayers/components/LayerCommon/LayerVisibilityToggle'; +import { LayerWrapper } from 'features/controlLayers/components/LayerCommon/LayerWrapper'; +import { layerSelected, selectRasterLayerOrThrow } from 'features/controlLayers/store/controlLayersSlice'; +import { memo, useCallback } from 'react'; + +import { RasterLayerOpacity } from './RasterLayerOpacity'; + +type Props = { + layerId: string; +}; + +export const RasterLayer = memo(({ layerId }: Props) => { + const dispatch = useAppDispatch(); + const isSelected = useAppSelector((s) => selectRasterLayerOrThrow(s.controlLayers.present, layerId).isSelected); + const onClick = useCallback(() => { + dispatch(layerSelected(layerId)); + }, [dispatch, layerId]); + const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }); + + return ( + + + + + + + + + + {isOpen && ( + + PLACEHOLDER + + )} + + ); +}); + +RasterLayer.displayName = 'RasterLayer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerOpacity.tsx new file mode 100644 index 0000000000..05e4acd849 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerOpacity.tsx @@ -0,0 +1,84 @@ +import { + CompositeNumberInput, + CompositeSlider, + Flex, + FormControl, + FormLabel, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { stopPropagation } from 'common/util/stopPropagation'; +import { useRasterLayerOpacity } from 'features/controlLayers/hooks/layerStateHooks'; +import { rasterLayerOpacityChanged } from 'features/controlLayers/store/controlLayersSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDropHalfFill } from 'react-icons/pi'; + +type Props = { + layerId: string; +}; + +const marks = [0, 25, 50, 75, 100]; +const formatPct = (v: number | string) => `${v} %`; + +export const RasterLayerOpacity = memo(({ layerId }: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const opacity = useRasterLayerOpacity(layerId); + const onChangeOpacity = useCallback( + (v: number) => { + dispatch(rasterLayerOpacityChanged({ layerId, opacity: v / 100 })); + }, + [dispatch, layerId] + ); + return ( + + + } + variant="ghost" + onDoubleClick={stopPropagation} + /> + + + + + + + {t('controlLayers.opacity')} + + + + + + + + ); +}); + +RasterLayerOpacity.displayName = 'RasterLayerOpacity'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts index 21e49ba15e..b036b25742 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/layerStateHooks.ts @@ -3,6 +3,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { isControlAdapterLayer, + isRasterLayer, isRegionalGuidanceLayer, selectControlLayersSlice, } from 'features/controlLayers/store/controlLayersSlice'; @@ -67,7 +68,7 @@ export const useLayerType = (layerId: string) => { return type; }; -export const useLayerOpacity = (layerId: string) => { +export const useCALayerOpacity = (layerId: string) => { const selectLayer = useMemo( () => createMemoizedSelector(selectControlLayersSlice, (controlLayers) => { @@ -80,3 +81,17 @@ export const useLayerOpacity = (layerId: string) => { const opacity = useAppSelector(selectLayer); return opacity; }; + +export const useRasterLayerOpacity = (layerId: string) => { + const selectLayer = useMemo( + () => + createMemoizedSelector(selectControlLayersSlice, (controlLayers) => { + const layer = controlLayers.present.layers.filter(isRasterLayer).find((l) => l.id === layerId); + assert(layer, `Layer ${layerId} not found`); + return Math.round(layer.opacity * 100); + }), + [layerId] + ); + const opacity = useAppSelector(selectLayer); + return opacity; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts b/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts index 354719c836..3a338b41a0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/naming.ts @@ -25,9 +25,11 @@ export const INITIAL_IMAGE_LAYER_NAME = 'initial_image_layer'; export const INITIAL_IMAGE_LAYER_IMAGE_NAME = 'initial_image_layer.image'; export const LAYER_BBOX_NAME = 'layer.bbox'; export const COMPOSITING_RECT_NAME = 'compositing-rect'; +export const RASTER_LAYER_NAME = 'raster_layer'; // Getters for non-singleton layer and object IDs export const getRGLayerId = (layerId: string) => `${RG_LAYER_NAME}_${layerId}`; +export const getRasterLayerId = (layerId: string) => `${RASTER_LAYER_NAME}_${layerId}`; export const getRGLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; export const getRGLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`; export const getRGLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts index e9374017e1..b0cf1707f0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersSlice.ts @@ -7,6 +7,7 @@ import { roundDownToMultiple } from 'common/util/roundDownToMultiple'; import { getCALayerId, getIPALayerId, + getRasterLayerId, getRGLayerId, getRGLayerLineId, getRGLayerRectId, @@ -55,6 +56,7 @@ import type { InitialImageLayer, IPAdapterLayer, Layer, + RasterLayer, RectShape, RegionalGuidanceLayer, Tool, @@ -87,12 +89,14 @@ export const isControlAdapterLayer = (layer?: Layer): layer is ControlAdapterLay layer?.type === 'control_adapter_layer'; export const isIPAdapterLayer = (layer?: Layer): layer is IPAdapterLayer => layer?.type === 'ip_adapter_layer'; export const isInitialImageLayer = (layer?: Layer): layer is InitialImageLayer => layer?.type === 'initial_image_layer'; +export const isRasterLayer = (layer?: Layer): layer is RasterLayer => layer?.type === 'raster_layer'; export const isRenderableLayer = ( layer?: Layer ): layer is RegionalGuidanceLayer | ControlAdapterLayer | InitialImageLayer => layer?.type === 'regional_guidance_layer' || layer?.type === 'control_adapter_layer' || - layer?.type === 'initial_image_layer'; + layer?.type === 'initial_image_layer' || + layer?.type === 'raster_layer'; export const selectCALayerOrThrow = (state: ControlLayersState, layerId: string): ControlAdapterLayer => { const layer = state.layers.find((l) => l.id === layerId); @@ -109,6 +113,11 @@ export const selectIILayerOrThrow = (state: ControlLayersState, layerId: string) assert(isInitialImageLayer(layer)); return layer; }; +export const selectRasterLayerOrThrow = (state: ControlLayersState, layerId: string): RasterLayer => { + const layer = state.layers.find((l) => l.id === layerId); + assert(isRasterLayer(layer)); + return layer; +}; const selectCAOrIPALayerOrThrow = ( state: ControlLayersState, layerId: string @@ -699,6 +708,34 @@ export const controlLayersSlice = createSlice({ }, //#endregion + //#region Raster Layers + rasterLayerAdded: { + reducer: (state, action: PayloadAction<{ layerId: string }>) => { + const { layerId } = action.payload; + const layer: RasterLayer = { + id: getRasterLayerId(layerId), + type: 'raster_layer', + isEnabled: true, + bbox: null, + bboxNeedsUpdate: false, + objects: [], + opacity: 1, + x: 0, + y: 0, + isSelected: true, + }; + state.layers.push(layer); + exclusivelySelectLayer(state, layer.id); + }, + prepare: () => ({ payload: { layerId: uuidv4() } }), + }, + rasterLayerOpacityChanged: (state, action: PayloadAction<{ layerId: string; opacity: number }>) => { + const { layerId, opacity } = action.payload; + const layer = selectRasterLayerOrThrow(state, layerId); + layer.opacity = opacity; + }, + //#endregion + //#region Globals positivePromptChanged: (state, action: PayloadAction) => { state.positivePrompt = action.payload; @@ -874,6 +911,9 @@ export const { iiLayerImageChanged, iiLayerOpacityChanged, iiLayerDenoisingStrengthChanged, + // Raster layers + rasterLayerAdded, + rasterLayerOpacityChanged, // Globals positivePromptChanged, negativePromptChanged, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index ff9f8e160d..03c47da357 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -58,6 +58,8 @@ const zRgbaColor = zRgbColor.extend({ type RgbaColor = z.infer; export const DEFAULT_RGBA_COLOR: RgbaColor = { r: 255, g: 255, b: 255, a: 1 }; +const zOpacity = z.number().gte(0).lte(1); + const zBrushLine = z.object({ id: z.string(), type: z.literal('brush_line'), @@ -86,6 +88,36 @@ const zRectShape = z.object({ }); export type RectShape = z.infer; +const zEllipseShape = z.object({ + id: z.string(), + type: z.literal('ellipse_shape'), + x: z.number(), + y: z.number(), + width: z.number().min(1), + height: z.number().min(1), + color: zRgbaColor, +}); +export type EllipseShape = z.infer; + +const zPolygonShape = z.object({ + id: z.string(), + type: z.literal('polygon_shape'), + points: zPoints, + color: zRgbaColor, +}); +export type PolygonShape = z.infer; + +const zImageObject = z.object({ + id: z.string(), + type: z.literal('image'), + image: zImageWithDims, + x: z.number(), + y: z.number(), + width: z.number().min(1), + height: z.number().min(1), +}); +export type ImageObject = z.infer; + const zLayerBase = z.object({ id: z.string(), isEnabled: z.boolean().default(true), @@ -105,9 +137,18 @@ const zRenderableLayerBase = zLayerBase.extend({ bboxNeedsUpdate: z.boolean(), }); +const zRasterLayer = zRenderableLayerBase.extend({ + type: z.literal('raster_layer'), + opacity: zOpacity, + objects: z.array( + z.discriminatedUnion('type', [zImageObject, zBrushLine, zEraserline, zRectShape, zEllipseShape, zPolygonShape]) + ), +}); +export type RasterLayer = z.infer; + const zControlAdapterLayer = zRenderableLayerBase.extend({ type: z.literal('control_adapter_layer'), - opacity: z.number().gte(0).lte(1), + opacity: zOpacity, isFilterEnabled: z.boolean(), controlAdapter: z.discriminatedUnion('type', [zControlNetConfigV2, zT2IAdapterConfigV2]), }); @@ -166,7 +207,7 @@ export type RegionalGuidanceLayer = z.infer; const zInitialImageLayer = zRenderableLayerBase.extend({ type: z.literal('initial_image_layer'), - opacity: z.number().gte(0).lte(1), + opacity: zOpacity, image: zImageWithDims.nullable(), denoisingStrength: zParameterStrength, }); @@ -177,6 +218,7 @@ export const zLayer = z.discriminatedUnion('type', [ zControlAdapterLayer, zIPAdapterLayer, zInitialImageLayer, + zRasterLayer, ]); export type Layer = z.infer;