feat(ui): layer opacity via caching

This commit is contained in:
psychedelicious 2024-08-19 22:51:40 +10:00
parent 5f2a7feeee
commit 0839eac0f7
20 changed files with 432 additions and 152 deletions

View File

@ -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>

View File

@ -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 />

View File

@ -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>
);
}
});

View File

@ -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) && (
<>

View File

@ -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>
);
}
});

View File

@ -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>
);
});

View File

@ -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>
);
}
});

View File

@ -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>
);
}
});

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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,
};
};
}

View File

@ -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'] }) => {

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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(),

View File

@ -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;
}

View File

@ -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,

View File

@ -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 };
};

View File

@ -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">