mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): layer opacity via caching
This commit is contained in:
parent
5f2a7feeee
commit
0839eac0f7
@ -1,4 +1,4 @@
|
||||
import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
|
||||
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useDefaultControlAdapter, useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
|
||||
import { controlLayerAdded, ipaAdded, rasterLayerAdded, rgAdded } from 'features/controlLayers/store/canvasV2Slice';
|
||||
@ -27,13 +27,12 @@ export const AddLayerButton = memo(() => {
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
leftIcon={<PiPlusBold />}
|
||||
variant="ghost"
|
||||
as={IconButton}
|
||||
aria-label={t('controlLayers.addLayer')}
|
||||
icon={<PiPlusBold />}
|
||||
variant="link"
|
||||
data-testid="control-layers-add-layer-menu-button"
|
||||
>
|
||||
{t('controlLayers.addLayer')}
|
||||
</MenuButton>
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem onClick={addRGLayer}>{t('controlLayers.regionalGuidanceLayer')}</MenuItem>
|
||||
<MenuItem onClick={addRasterLayer}>{t('controlLayers.rasterLayer')}</MenuItem>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { CanvasEntityOpacity } from 'features/controlLayers/components/common/CanvasEntityOpacity';
|
||||
import { ControlLayerEntityList } from 'features/controlLayers/components/ControlLayer/ControlLayerEntityList';
|
||||
import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask';
|
||||
import { IPAdapterList } from 'features/controlLayers/components/IPAdapter/IPAdapterList';
|
||||
@ -10,7 +11,8 @@ import { memo } from 'react';
|
||||
export const CanvasEntityList = memo(() => {
|
||||
return (
|
||||
<ScrollableContent>
|
||||
<Flex flexDir="column" gap={2} data-testid="control-layers-layer-list">
|
||||
<Flex flexDir="column" gap={4} pt={2} data-testid="control-layers-layer-list">
|
||||
<CanvasEntityOpacity />
|
||||
<InpaintMask />
|
||||
<RegionalGuidanceEntityList />
|
||||
<IPAdapterList />
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle';
|
||||
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
|
||||
import { ControlLayer } from 'features/controlLayers/components/ControlLayer/ControlLayer';
|
||||
import { mapId } from 'features/controlLayers/konva/util';
|
||||
import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice';
|
||||
@ -22,15 +22,15 @@ export const ControlLayerEntityList = memo(() => {
|
||||
|
||||
if (layerIds.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.controlLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="control_layer"
|
||||
title={t('controlLayers.controlLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{layerIds.map((id) => (
|
||||
<ControlLayer key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1,10 +1,7 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { AddLayerButton } from 'features/controlLayers/components/AddLayerButton';
|
||||
import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList';
|
||||
import { Filter } from 'features/controlLayers/components/Filters/Filter';
|
||||
import { ResetAllEntitiesButton } from 'features/controlLayers/components/ResetAllEntitiesButton';
|
||||
import { $filteringEntity } from 'features/controlLayers/store/canvasV2Slice';
|
||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { memo } from 'react';
|
||||
@ -15,13 +12,7 @@ export const ControlLayersPanelContent = memo(() => {
|
||||
return (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel id="canvas-entity-list-panel" order={0}>
|
||||
<Flex flexDir="column" gap={2} w="full" h="full">
|
||||
<Flex justifyContent="space-around">
|
||||
<AddLayerButton />
|
||||
<ResetAllEntitiesButton />
|
||||
</Flex>
|
||||
<CanvasEntityList />
|
||||
</Flex>
|
||||
<CanvasEntityList />
|
||||
</Panel>
|
||||
{Boolean(filteringEntity) && (
|
||||
<>
|
||||
|
@ -1,7 +1,7 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle';
|
||||
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
|
||||
import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter';
|
||||
import { mapId } from 'features/controlLayers/konva/util';
|
||||
import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice';
|
||||
@ -23,15 +23,15 @@ export const IPAdapterList = memo(() => {
|
||||
|
||||
if (ipaIds.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.ipAdapters_withCount', { count: ipaIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="ip_adapter"
|
||||
title={t('controlLayers.ipAdapters_withCount', { count: ipaIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{ipaIds.map((id) => (
|
||||
<IPAdapter key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Spacer } from '@invoke-ai/ui-library';
|
||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
|
||||
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
|
||||
import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle';
|
||||
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
|
||||
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
|
||||
import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle';
|
||||
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
@ -18,8 +18,8 @@ export const InpaintMask = memo(() => {
|
||||
const isSelected = useAppSelector((s) => Boolean(s.canvasV2.selectedEntityIdentifier?.type === 'inpaint_mask'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<CanvasEntityGroupTitle title={t('controlLayers.inpaintMask')} isSelected={isSelected} />
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<CanvasEntityGroupList title={t('controlLayers.inpaintMask')} isSelected={isSelected} type="inpaint_mask" />
|
||||
<EntityIdentifierContext.Provider value={entityIdentifier}>
|
||||
<CanvasEntityContainer>
|
||||
<CanvasEntityHeader>
|
||||
@ -30,7 +30,7 @@ export const InpaintMask = memo(() => {
|
||||
</CanvasEntityHeader>
|
||||
</CanvasEntityContainer>
|
||||
</EntityIdentifierContext.Provider>
|
||||
</>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle';
|
||||
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
|
||||
import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer';
|
||||
import { mapId } from 'features/controlLayers/konva/util';
|
||||
import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice';
|
||||
@ -22,15 +22,15 @@ export const RasterLayerEntityList = memo(() => {
|
||||
|
||||
if (layerIds.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.rasterLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="raster_layer"
|
||||
title={t('controlLayers.rasterLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{layerIds.map((id) => (
|
||||
<RasterLayer key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle';
|
||||
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
|
||||
import { RegionalGuidance } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidance';
|
||||
import { mapId } from 'features/controlLayers/konva/util';
|
||||
import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice';
|
||||
@ -22,15 +22,15 @@ export const RegionalGuidanceEntityList = memo(() => {
|
||||
|
||||
if (rgIds.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.regionalGuidance_withCount', { count: rgIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="regional_guidance"
|
||||
title={t('controlLayers.regionalGuidance_withCount', { count: rgIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{rgIds.map((id) => (
|
||||
<RegionalGuidance key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -0,0 +1,45 @@
|
||||
import { Flex, Switch, Text } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
allEntitiesOfTypeToggled,
|
||||
selectAllEntitiesOfType,
|
||||
selectCanvasV2Slice,
|
||||
} from 'features/controlLayers/store/canvasV2Slice';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
title: string;
|
||||
isSelected: boolean;
|
||||
type: CanvasEntityIdentifier['type'];
|
||||
}>;
|
||||
|
||||
export const CanvasEntityGroupList = memo(({ title, isSelected, type, children }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const selectAreAllEnabled = useMemo(
|
||||
() =>
|
||||
createSelector(selectCanvasV2Slice, (canvasV2) => {
|
||||
return selectAllEntitiesOfType(canvasV2, type).every((entity) => entity.isEnabled);
|
||||
}),
|
||||
[type]
|
||||
);
|
||||
const areAllEnabled = useAppSelector(selectAreAllEnabled);
|
||||
const onChange = useCallback(() => {
|
||||
dispatch(allEntitiesOfTypeToggled({ type }));
|
||||
}, [dispatch, type]);
|
||||
return (
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Flex justifyContent="space-between" alignItems="center">
|
||||
<Text color={isSelected ? 'base.200' : 'base.500'} fontWeight="semibold" userSelect="none">
|
||||
{title}
|
||||
</Text>
|
||||
<Switch size="sm" isChecked={areAllEnabled} onChange={onChange} pe={1} />
|
||||
</Flex>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityGroupList.displayName = 'CanvasEntityGroupList';
|
@ -1,17 +0,0 @@
|
||||
import { Text } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
export const CanvasEntityGroupTitle = memo(({ title, isSelected }: Props) => {
|
||||
return (
|
||||
<Text color={isSelected ? 'base.200' : 'base.500'} fontWeight="semibold" userSelect="none">
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityGroupTitle.displayName = 'CanvasEntityGroupTitle';
|
@ -0,0 +1,180 @@
|
||||
import {
|
||||
$shift,
|
||||
CompositeSlider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { snapToNearest } from 'features/controlLayers/konva/util';
|
||||
import { entityOpacityChanged, selectEntity } from 'features/controlLayers/store/canvasV2Slice';
|
||||
import { isDrawableEntity } from 'features/controlLayers/store/types';
|
||||
import { clamp, round } from 'lodash-es';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretDownBold } from 'react-icons/pi';
|
||||
|
||||
function formatPct(v: number | string) {
|
||||
if (isNaN(Number(v))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${round(Number(v), 2).toLocaleString()}%`;
|
||||
}
|
||||
|
||||
function mapSliderValueToOpacity(value: number) {
|
||||
return value / 100;
|
||||
}
|
||||
|
||||
function mapOpacityToSliderValue(opacity: number) {
|
||||
return opacity * 100;
|
||||
}
|
||||
|
||||
function formatSliderValue(value: number) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const marks = [
|
||||
mapOpacityToSliderValue(0),
|
||||
mapOpacityToSliderValue(0.25),
|
||||
mapOpacityToSliderValue(0.5),
|
||||
mapOpacityToSliderValue(0.75),
|
||||
mapOpacityToSliderValue(1),
|
||||
];
|
||||
|
||||
const sliderDefaultValue = mapOpacityToSliderValue(100);
|
||||
|
||||
const snapCandidates = marks.slice(1, marks.length - 1);
|
||||
|
||||
export const CanvasEntityOpacity = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedEntityIdentifier = useAppSelector((s) => s.canvasV2.selectedEntityIdentifier);
|
||||
const opacity = useAppSelector((s) => {
|
||||
const selectedEntityIdentifier = s.canvasV2.selectedEntityIdentifier;
|
||||
if (!selectedEntityIdentifier) {
|
||||
return null;
|
||||
}
|
||||
const selectedEntity = selectEntity(s.canvasV2, selectedEntityIdentifier);
|
||||
if (!selectedEntity) {
|
||||
return null;
|
||||
}
|
||||
if (!isDrawableEntity(selectedEntity)) {
|
||||
return null;
|
||||
}
|
||||
return selectedEntity.opacity;
|
||||
});
|
||||
|
||||
const [localOpacity, setLocalOpacity] = useState((opacity ?? 1) * 100);
|
||||
|
||||
const onChangeSlider = useCallback(
|
||||
(opacity: number) => {
|
||||
if (!selectedEntityIdentifier) {
|
||||
return;
|
||||
}
|
||||
let snappedOpacity = opacity;
|
||||
// Do not snap if shift key is held
|
||||
if (!$shift.get()) {
|
||||
snappedOpacity = snapToNearest(opacity, snapCandidates, 2);
|
||||
}
|
||||
const mappedOpacity = mapSliderValueToOpacity(snappedOpacity);
|
||||
|
||||
dispatch(entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: mappedOpacity }));
|
||||
},
|
||||
[dispatch, selectedEntityIdentifier]
|
||||
);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
if (!selectedEntityIdentifier) {
|
||||
return;
|
||||
}
|
||||
if (isNaN(Number(localOpacity))) {
|
||||
setLocalOpacity(100);
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: clamp(localOpacity / 100, 0, 1) })
|
||||
);
|
||||
}, [dispatch, localOpacity, selectedEntityIdentifier]);
|
||||
|
||||
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
|
||||
setLocalOpacity(valueAsNumber);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
}
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalOpacity((opacity ?? 1) * 100);
|
||||
}, [opacity]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.opacity')}</FormLabel>
|
||||
<PopoverAnchor>
|
||||
<NumberInput
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={localOpacity}
|
||||
onChange={onChangeNumberInput}
|
||||
onBlur={onBlur}
|
||||
w="76px"
|
||||
format={formatPct}
|
||||
defaultValue={1}
|
||||
onKeyDown={onKeyDown}
|
||||
clampValueOnBlur={false}
|
||||
>
|
||||
<NumberInputField paddingInlineEnd={7} />
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="open-slider"
|
||||
icon={<PiCaretDownBold />}
|
||||
size="sm"
|
||||
variant="link"
|
||||
position="absolute"
|
||||
insetInlineEnd={0}
|
||||
h="full"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</NumberInput>
|
||||
</PopoverAnchor>
|
||||
</FormControl>
|
||||
<PopoverContent w={200} pt={0} pb={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider
|
||||
min={0}
|
||||
max={100}
|
||||
value={localOpacity}
|
||||
onChange={onChangeSlider}
|
||||
defaultValue={sliderDefaultValue}
|
||||
marks={marks}
|
||||
formatValue={formatSliderValue}
|
||||
alwaysShowMarks
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityOpacity.displayName = 'CanvasEntityOpacity';
|
@ -1,27 +0,0 @@
|
||||
import type { JSONObject } from 'common/types';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
export abstract class CanvasEntity {
|
||||
id: string;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
|
||||
constructor(id: string, manager: CanvasManager) {
|
||||
this.id = id;
|
||||
this.manager = manager;
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
}
|
||||
/**
|
||||
* Get a serializable representation of the entity.
|
||||
*/
|
||||
abstract repr(): JSONObject;
|
||||
|
||||
getLoggingContext = (extra?: Record<string, unknown>) => {
|
||||
return {
|
||||
...this.manager.getLoggingContext(),
|
||||
layerId: this.id,
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
}
|
@ -4,11 +4,12 @@ import { CanvasFilter } from 'features/controlLayers/konva/CanvasFilter';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer';
|
||||
import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer';
|
||||
import type {
|
||||
CanvasControlLayerState,
|
||||
CanvasEntityIdentifier,
|
||||
CanvasRasterLayerState,
|
||||
CanvasV2State,
|
||||
import {
|
||||
type CanvasControlLayerState,
|
||||
type CanvasEntityIdentifier,
|
||||
type CanvasRasterLayerState,
|
||||
type CanvasV2State,
|
||||
getEntityIdentifier,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import { get } from 'lodash-es';
|
||||
@ -61,7 +62,7 @@ export class CanvasLayerAdapter {
|
||||
* Get this entity's entity identifier
|
||||
*/
|
||||
getEntityIdentifier = (): CanvasEntityIdentifier => {
|
||||
return { id: this.id, type: this.state.type };
|
||||
return getEntityIdentifier(this.state);
|
||||
};
|
||||
|
||||
destroy = (): void => {
|
||||
@ -97,7 +98,7 @@ export class CanvasLayerAdapter {
|
||||
this.transformer.updatePosition({ position });
|
||||
}
|
||||
if (this.isFirstRender || opacity !== this.state.opacity) {
|
||||
this.updateOpacity({ opacity });
|
||||
this.renderer.updateOpacity(opacity);
|
||||
}
|
||||
// this.transformer.syncInteractionState();
|
||||
|
||||
@ -113,6 +114,7 @@ export class CanvasLayerAdapter {
|
||||
this.log.trace('Updating visibility');
|
||||
const isEnabled = get(arg, 'isEnabled', this.state.isEnabled);
|
||||
this.konva.layer.visible(isEnabled);
|
||||
this.renderer.syncCache(isEnabled);
|
||||
};
|
||||
|
||||
updateObjects = async (arg?: { objects: CanvasRasterLayerState['objects'] }) => {
|
||||
|
@ -316,10 +316,10 @@ export class CanvasManager {
|
||||
if (this._isFirstRender || state.rasterLayers.entities !== this._prevState.rasterLayers.entities) {
|
||||
this.log.debug('Rendering raster layers');
|
||||
|
||||
for (const canvasLayer of this.rasterLayerAdapters.values()) {
|
||||
if (!state.rasterLayers.entities.find((l) => l.id === canvasLayer.id)) {
|
||||
await canvasLayer.destroy();
|
||||
this.rasterLayerAdapters.delete(canvasLayer.id);
|
||||
for (const entityAdapter of this.rasterLayerAdapters.values()) {
|
||||
if (!state.rasterLayers.entities.find((l) => l.id === entityAdapter.id)) {
|
||||
await entityAdapter.destroy();
|
||||
this.rasterLayerAdapters.delete(entityAdapter.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -341,10 +341,10 @@ export class CanvasManager {
|
||||
if (this._isFirstRender || state.controlLayers.entities !== this._prevState.controlLayers.entities) {
|
||||
this.log.debug('Rendering control layers');
|
||||
|
||||
for (const canvasLayer of this.controlLayerAdapters.values()) {
|
||||
if (!state.controlLayers.entities.find((l) => l.id === canvasLayer.id)) {
|
||||
await canvasLayer.destroy();
|
||||
this.controlLayerAdapters.delete(canvasLayer.id);
|
||||
for (const entityAdapter of this.controlLayerAdapters.values()) {
|
||||
if (!state.controlLayers.entities.find((l) => l.id === entityAdapter.id)) {
|
||||
await entityAdapter.destroy();
|
||||
this.controlLayerAdapters.delete(entityAdapter.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,11 +3,12 @@ import { deepClone } from 'common/util/deepClone';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer';
|
||||
import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer';
|
||||
import type {
|
||||
CanvasEntityIdentifier,
|
||||
CanvasInpaintMaskState,
|
||||
CanvasRegionalGuidanceState,
|
||||
CanvasV2State,
|
||||
import {
|
||||
type CanvasEntityIdentifier,
|
||||
type CanvasInpaintMaskState,
|
||||
type CanvasRegionalGuidanceState,
|
||||
type CanvasV2State,
|
||||
getEntityIdentifier,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import { get } from 'lodash-es';
|
||||
@ -59,7 +60,7 @@ export class CanvasMaskAdapter {
|
||||
* Get this entity's entity identifier
|
||||
*/
|
||||
getEntityIdentifier = (): CanvasEntityIdentifier => {
|
||||
return { id: this.id, type: this.state.type };
|
||||
return getEntityIdentifier(this.state)
|
||||
};
|
||||
|
||||
destroy = (): void => {
|
||||
@ -99,7 +100,11 @@ export class CanvasMaskAdapter {
|
||||
}
|
||||
|
||||
if (this.isFirstRender || state.fill !== this.state.fill) {
|
||||
this.renderer.updateCompositingRect(state.fill);
|
||||
this.renderer.updateCompositingRectFill(state.fill);
|
||||
}
|
||||
|
||||
if (this.isFirstRender) {
|
||||
this.renderer.updateCompositingRectSize();
|
||||
}
|
||||
|
||||
// this.transformer.syncInteractionState();
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { JSONObject } from 'common/types';
|
||||
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
|
||||
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { CanvasBrushLineRenderer } from 'features/controlLayers/konva/CanvasBrushLine';
|
||||
import { CanvasEraserLineRenderer } from 'features/controlLayers/konva/CanvasEraserLine';
|
||||
@ -21,7 +21,6 @@ import type {
|
||||
} from 'features/controlLayers/store/types';
|
||||
import { imageDTOToImageObject } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { RectConfig } from 'konva/lib/shapes/Rect';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { Logger } from 'roarr';
|
||||
import { getImageDTO, uploadImage } from 'services/api/endpoints/images';
|
||||
@ -149,7 +148,8 @@ export class CanvasObjectRenderer {
|
||||
this.subscriptions.add(
|
||||
this.manager.stateApi.$stageAttrs.listen(() => {
|
||||
if (this.konva.compositing && this.parent.type === 'mask_adapter') {
|
||||
this.updateCompositingRect(this.parent.state.fill);
|
||||
this.updateCompositingRectFill(this.parent.state.fill);
|
||||
this.updateCompositingRectSize();
|
||||
}
|
||||
})
|
||||
);
|
||||
@ -162,6 +162,7 @@ export class CanvasObjectRenderer {
|
||||
*/
|
||||
render = async (objectStates: AnyObjectState[]): Promise<boolean> => {
|
||||
let didRender = false;
|
||||
|
||||
const objectIds = objectStates.map((objectState) => objectState.id);
|
||||
|
||||
for (const renderer of this.renderers.values()) {
|
||||
@ -180,32 +181,61 @@ export class CanvasObjectRenderer {
|
||||
didRender = (await this.renderObject(this.buffer)) || didRender;
|
||||
}
|
||||
|
||||
this.syncCache(didRender);
|
||||
|
||||
return didRender;
|
||||
};
|
||||
|
||||
updateCompositingRect = (fill: Fill) => {
|
||||
this.log.trace('Updating compositing rect');
|
||||
syncCache = (force: boolean = false) => {
|
||||
if (this.renderers.size === 0) {
|
||||
this.log.trace('Clearing object group cache');
|
||||
this.konva.objectGroup.clearCache();
|
||||
} else if (force || !this.konva.objectGroup.isCached()) {
|
||||
this.log.trace('Caching object group');
|
||||
this.konva.objectGroup.clearCache();
|
||||
this.konva.objectGroup.cache();
|
||||
}
|
||||
};
|
||||
|
||||
updateCompositingRectFill = (fill: Fill) => {
|
||||
this.log.trace('Updating compositing rect fill');
|
||||
assert(this.konva.compositing, 'Missing compositing rect');
|
||||
|
||||
if (fill.style === 'solid') {
|
||||
this.konva.compositing.rect.setAttrs({
|
||||
fill: rgbColorToString(fill.color),
|
||||
fillPriority: 'color',
|
||||
});
|
||||
} else {
|
||||
this.konva.compositing.rect.setAttrs({
|
||||
fillPriority: 'pattern',
|
||||
});
|
||||
setFillPatternImage(this.konva.compositing.rect, fill.style, fill.color);
|
||||
}
|
||||
};
|
||||
|
||||
updateCompositingRectSize = () => {
|
||||
this.log.trace('Updating compositing rect size');
|
||||
assert(this.konva.compositing, 'Missing compositing rect');
|
||||
|
||||
const { x, y, width, height, scale } = this.manager.stateApi.$stageAttrs.get();
|
||||
|
||||
const attrs: RectConfig = {
|
||||
this.konva.compositing.rect.setAttrs({
|
||||
x: -x / scale,
|
||||
y: -y / scale,
|
||||
width: width / scale,
|
||||
height: height / scale,
|
||||
};
|
||||
fillPatternScaleX: 1 / scale,
|
||||
fillPatternScaleY: 1 / scale,
|
||||
});
|
||||
};
|
||||
|
||||
if (fill.style === 'solid') {
|
||||
attrs.fill = rgbaColorToString(fill.color);
|
||||
attrs.fillPriority = 'color';
|
||||
this.konva.compositing.rect.setAttrs(attrs);
|
||||
updateOpacity = (opacity: number) => {
|
||||
this.log.trace('Updating opacity');
|
||||
if (this.konva.compositing) {
|
||||
this.konva.compositing.group.opacity(opacity);
|
||||
} else {
|
||||
attrs.fillPatternScaleX = 1 / scale;
|
||||
attrs.fillPatternScaleY = 1 / scale;
|
||||
attrs.fillPriority = 'pattern';
|
||||
this.konva.compositing.rect.setAttrs(attrs);
|
||||
setFillPatternImage(this.konva.compositing.rect, fill.style, fill.color);
|
||||
this.konva.objectGroup.opacity(opacity);
|
||||
}
|
||||
};
|
||||
|
||||
@ -266,6 +296,10 @@ export class CanvasObjectRenderer {
|
||||
didRender = await renderer.update(objectState, force || isFirstRender);
|
||||
}
|
||||
|
||||
if (didRender && this.konva.objectGroup.isCached()) {
|
||||
this.konva.objectGroup.clearCache();
|
||||
}
|
||||
|
||||
return didRender;
|
||||
};
|
||||
|
||||
@ -421,7 +455,7 @@ export class CanvasObjectRenderer {
|
||||
imageDTO = await uploadImage(blob, `${this.id}_rasterized.png`, 'other', true);
|
||||
const imageObject = imageDTOToImageObject(imageDTO);
|
||||
if (replaceObjects) {
|
||||
await this.renderObject(imageObject, true);
|
||||
await this.renderObject(imageObject, true);
|
||||
}
|
||||
this.manager.stateApi.rasterizeEntity({
|
||||
entityIdentifier: this.parent.getEntityIdentifier(),
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
|
||||
import type { FillStyle, RgbaColor } from 'features/controlLayers/store/types';
|
||||
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
||||
import type { FillStyle, RgbColor } from 'features/controlLayers/store/types';
|
||||
|
||||
import crosshatch from './pattern-crosshatch.svg?raw';
|
||||
import diagonal from './pattern-diagonal.svg?raw';
|
||||
@ -7,7 +7,7 @@ import grid from './pattern-grid.svg?raw';
|
||||
import horizontal from './pattern-horizontal.svg?raw';
|
||||
import vertical from './pattern-vertical.svg?raw';
|
||||
|
||||
export function getPatternSVG(pattern: Exclude<FillStyle, 'solid'>, color: RgbaColor) {
|
||||
export function getPatternSVG(pattern: Exclude<FillStyle, 'solid'>, color: RgbColor) {
|
||||
let content: string = 'data:image/svg+xml;utf8,';
|
||||
if (pattern === 'crosshatch') {
|
||||
content += crosshatch;
|
||||
@ -21,7 +21,7 @@ export function getPatternSVG(pattern: Exclude<FillStyle, 'solid'>, color: RgbaC
|
||||
content += grid;
|
||||
}
|
||||
|
||||
content = content.replaceAll('stroke:black', `stroke:${rgbaColorToString(color)}`);
|
||||
content = content.replaceAll('stroke:black', `stroke:${rgbColorToString(color)}`);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import { assert } from 'tsafe';
|
||||
import type {
|
||||
CanvasControlLayerState,
|
||||
CanvasEntityIdentifier,
|
||||
CanvasEntityState,
|
||||
CanvasInpaintMaskState,
|
||||
CanvasRasterLayerState,
|
||||
CanvasRegionalGuidanceState,
|
||||
@ -163,6 +164,22 @@ export function selectEntity(state: CanvasV2State, { id, type }: CanvasEntityIde
|
||||
}
|
||||
}
|
||||
|
||||
export function selectAllEntitiesOfType(state: CanvasV2State, type: CanvasEntityState['type']): CanvasEntityState[] {
|
||||
if (type === 'raster_layer') {
|
||||
return state.rasterLayers.entities;
|
||||
} else if (type === 'control_layer') {
|
||||
return state.controlLayers.entities;
|
||||
} else if (type === 'inpaint_mask') {
|
||||
return [state.inpaintMask];
|
||||
} else if (type === 'regional_guidance') {
|
||||
return state.regions.entities;
|
||||
} else if (type === 'ip_adapter') {
|
||||
return state.ipAdapters.entities;
|
||||
} else {
|
||||
assert(false, 'Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
const invalidateRasterizationCaches = (
|
||||
entity: CanvasRasterLayerState | CanvasControlLayerState | CanvasInpaintMaskState | CanvasRegionalGuidanceState,
|
||||
state: CanvasV2State
|
||||
@ -410,6 +427,48 @@ export const canvasV2Slice = createSlice({
|
||||
moveToStart(state.regions.entities, entity);
|
||||
}
|
||||
},
|
||||
entityOpacityChanged: (state, action: PayloadAction<EntityIdentifierPayload<{ opacity: number }>>) => {
|
||||
const { entityIdentifier, opacity } = action.payload;
|
||||
const entity = selectEntity(state, entityIdentifier);
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
if (entity.type === 'ip_adapter') {
|
||||
return;
|
||||
}
|
||||
entity.opacity = opacity;
|
||||
},
|
||||
allEntitiesOfTypeToggled: (state, action: PayloadAction<{ type: CanvasEntityIdentifier['type'] }>) => {
|
||||
const { type } = action.payload;
|
||||
let entities: (
|
||||
| CanvasRasterLayerState
|
||||
| CanvasControlLayerState
|
||||
| CanvasInpaintMaskState
|
||||
| CanvasRegionalGuidanceState
|
||||
)[];
|
||||
|
||||
switch (type) {
|
||||
case 'raster_layer':
|
||||
entities = state.rasterLayers.entities;
|
||||
break;
|
||||
case 'control_layer':
|
||||
entities = state.controlLayers.entities;
|
||||
break;
|
||||
case 'inpaint_mask':
|
||||
entities = [state.inpaintMask];
|
||||
break;
|
||||
case 'regional_guidance':
|
||||
entities = state.regions.entities;
|
||||
break;
|
||||
default:
|
||||
assert(false, 'Not implemented');
|
||||
}
|
||||
|
||||
const allEnabled = entities.every((entity) => entity.isEnabled);
|
||||
for (const entity of entities) {
|
||||
entity.isEnabled = !allEnabled;
|
||||
}
|
||||
},
|
||||
allEntitiesDeleted: (state) => {
|
||||
state.ipAdapters = deepClone(initialState.ipAdapters);
|
||||
state.rasterLayers = deepClone(initialState.rasterLayers);
|
||||
@ -490,6 +549,8 @@ export const {
|
||||
entityArrangedToFront,
|
||||
entityArrangedBackwardOne,
|
||||
entityArrangedToBack,
|
||||
entityOpacityChanged,
|
||||
allEntitiesOfTypeToggled,
|
||||
// bbox
|
||||
bboxChanged,
|
||||
bboxScaledSizeChanged,
|
||||
|
@ -644,7 +644,7 @@ const zMaskObject = z
|
||||
const zFillStyle = z.enum(['solid', 'grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical']);
|
||||
export type FillStyle = z.infer<typeof zFillStyle>;
|
||||
export const isFillStyle = (v: unknown): v is FillStyle => zFillStyle.safeParse(v).success;
|
||||
const zFill = z.object({ style: zFillStyle, color: zRgbaColor });
|
||||
const zFill = z.object({ style: zFillStyle, color: zRgbColor });
|
||||
export type Fill = z.infer<typeof zFill>;
|
||||
|
||||
const zImageCache = z.object({
|
||||
@ -670,6 +670,7 @@ export const zCanvasRegionalGuidanceState = z.object({
|
||||
type: z.literal('regional_guidance'),
|
||||
isEnabled: z.boolean(),
|
||||
position: zCoordinate,
|
||||
opacity: zOpacity,
|
||||
objects: z.array(zCanvasObjectState),
|
||||
fill: zFill,
|
||||
positivePrompt: zParameterPositivePrompt.nullable(),
|
||||
@ -686,6 +687,7 @@ const zCanvasInpaintMaskState = z.object({
|
||||
isEnabled: z.boolean(),
|
||||
position: zCoordinate,
|
||||
fill: zFill,
|
||||
opacity: zOpacity,
|
||||
objects: z.array(zCanvasObjectState),
|
||||
rasterizationCache: z.array(zImageCache),
|
||||
});
|
||||
@ -946,20 +948,16 @@ export type PositionChangedArg = { id: string; position: Coordinate };
|
||||
export type ScaleChangedArg = { id: string; scale: Coordinate; position: Coordinate };
|
||||
export type BboxChangedArg = { id: string; bbox: Rect | null };
|
||||
|
||||
export type EntityIdentifierPayload = { entityIdentifier: CanvasEntityIdentifier };
|
||||
export type EntityMovedPayload = { entityIdentifier: CanvasEntityIdentifier; position: Coordinate };
|
||||
export type EntityBrushLineAddedPayload = { entityIdentifier: CanvasEntityIdentifier; brushLine: CanvasBrushLineState };
|
||||
export type EntityEraserLineAddedPayload = {
|
||||
entityIdentifier: CanvasEntityIdentifier;
|
||||
eraserLine: CanvasEraserLineState;
|
||||
};
|
||||
export type EntityRectAddedPayload = { entityIdentifier: CanvasEntityIdentifier; rect: CanvasRectState };
|
||||
export type EntityRasterizedPayload = {
|
||||
entityIdentifier: CanvasEntityIdentifier;
|
||||
export type EntityIdentifierPayload<T = object> = { entityIdentifier: CanvasEntityIdentifier } & T;
|
||||
export type EntityMovedPayload = EntityIdentifierPayload<{ position: Coordinate }>;
|
||||
export type EntityBrushLineAddedPayload = EntityIdentifierPayload<{ brushLine: CanvasBrushLineState }>;
|
||||
export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{ eraserLine: CanvasEraserLineState }>;
|
||||
export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>;
|
||||
export type EntityRasterizedPayload = EntityIdentifierPayload<{
|
||||
imageObject: CanvasImageState;
|
||||
rect: Rect;
|
||||
replaceObjects: boolean;
|
||||
};
|
||||
}>;
|
||||
export type ImageObjectAddedArg = { id: string; imageDTO: ImageDTO; position?: Coordinate };
|
||||
|
||||
//#region Type guards
|
||||
@ -1000,3 +998,7 @@ export function isDrawableEntityAdapter(
|
||||
): adapter is CanvasLayerAdapter | CanvasMaskAdapter {
|
||||
return adapter instanceof CanvasLayerAdapter || adapter instanceof CanvasMaskAdapter;
|
||||
}
|
||||
|
||||
export const getEntityIdentifier = (entity: CanvasEntityState): CanvasEntityIdentifier => {
|
||||
return { id: entity.id, type: entity.type };
|
||||
};
|
||||
|
@ -1,8 +1,9 @@
|
||||
import type { ChakraProps } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
|
||||
import { AddLayerButton } from 'features/controlLayers/components/AddLayerButton';
|
||||
import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent';
|
||||
import { $isPreviewVisible } from 'features/controlLayers/store/canvasV2Slice';
|
||||
import { selectEntityCount } from 'features/controlLayers/store/selectors';
|
||||
@ -89,7 +90,7 @@ const ParametersPanelTextToImage = () => {
|
||||
gap={2}
|
||||
onChange={onChangeTabs}
|
||||
>
|
||||
<TabList gap={2} fontSize="sm" borderColor="base.800">
|
||||
<TabList gap={2} fontSize="sm" borderColor="base.800" alignItems="center" w="full">
|
||||
<Tab sx={baseStyles} _selected={selectedStyles} data-testid="generation-tab-settings-tab-button">
|
||||
{t('common.settingsLabel')}
|
||||
</Tab>
|
||||
@ -100,6 +101,8 @@ const ParametersPanelTextToImage = () => {
|
||||
>
|
||||
{controlLayersTitle}
|
||||
</Tab>
|
||||
<Spacer />
|
||||
<AddLayerButton />
|
||||
</TabList>
|
||||
<TabPanels w="full" h="full">
|
||||
<TabPanel p={0} w="full" h="full">
|
||||
|
Loading…
Reference in New Issue
Block a user