fix(ui): region rendering

This commit is contained in:
psychedelicious 2024-07-03 19:57:07 +10:00
parent 54e1eae509
commit 0b71ac258c
6 changed files with 171 additions and 137 deletions

View File

@ -181,9 +181,6 @@ export class KonvaNodeManager {
renderRegions() { renderRegions() {
const { entities } = this.stateApi.getRegionsState(); const { entities } = this.stateApi.getRegionsState();
const maskOpacity = this.stateApi.getMaskOpacity();
const toolState = this.stateApi.getToolState();
const selectedEntity = this.stateApi.getSelectedEntity();
// Destroy the konva nodes for nonexistent entities // Destroy the konva nodes for nonexistent entities
for (const canvasRegion of this.regions.values()) { for (const canvasRegion of this.regions.values()) {
@ -196,11 +193,11 @@ export class KonvaNodeManager {
for (const entity of entities) { for (const entity of entities) {
let adapter = this.regions.get(entity.id); let adapter = this.regions.get(entity.id);
if (!adapter) { if (!adapter) {
adapter = new CanvasRegion(entity, this.stateApi.onPosChanged); adapter = new CanvasRegion(entity, this);
this.regions.set(adapter.id, adapter); this.regions.set(adapter.id, adapter);
this.stage.add(adapter.layer); this.stage.add(adapter.layer);
} }
adapter.render(entity, toolState.selected, selectedEntity, maskOpacity); adapter.render(entity);
} }
} }

View File

@ -22,11 +22,7 @@ export class CanvasInpaintMask {
constructor(entity: InpaintMaskEntity, manager: KonvaNodeManager) { constructor(entity: InpaintMaskEntity, manager: KonvaNodeManager) {
this.id = entity.id; this.id = entity.id;
this.manager = manager; this.manager = manager;
this.layer = new Konva.Layer({ this.layer = new Konva.Layer({ id: entity.id });
id: entity.id,
draggable: true,
dragDistance: 0,
});
this.group = new Konva.Group({ this.group = new Konva.Group({
id: getObjectGroupId(this.layer.id(), uuidv4()), id: getObjectGroupId(this.layer.id(), uuidv4()),

View File

@ -1,42 +1,61 @@
import { rgbColorToString } from 'common/util/colorCodeTransformers'; import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { getObjectGroupId } from 'features/controlLayers/konva/naming'; import { getObjectGroupId } from 'features/controlLayers/konva/naming';
import type { StateApi } from 'features/controlLayers/konva/nodeManager'; import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import { getNodeBboxFast } from 'features/controlLayers/konva/renderers/entityBbox'; import { getNodeBboxFast } from 'features/controlLayers/konva/renderers/entityBbox';
import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects'; import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
import { mapId } from 'features/controlLayers/konva/util'; import { mapId } from 'features/controlLayers/konva/util';
import type { CanvasEntityIdentifier, RegionEntity, Tool } from 'features/controlLayers/store/types'; import {
isDrawingTool,
type RegionEntity,
} from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export class CanvasRegion { export class CanvasRegion {
id: string; id: string;
manager: KonvaNodeManager;
layer: Konva.Layer; layer: Konva.Layer;
group: Konva.Group; group: Konva.Group;
objectsGroup: Konva.Group;
compositingRect: Konva.Rect; compositingRect: Konva.Rect;
transformer: Konva.Transformer;
objects: Map<string, KonvaBrushLine | KonvaEraserLine | KonvaRect>; objects: Map<string, KonvaBrushLine | KonvaEraserLine | KonvaRect>;
constructor(entity: RegionEntity, onPosChanged: StateApi['onPosChanged']) { constructor(entity: RegionEntity, manager: KonvaNodeManager) {
this.id = entity.id; this.id = entity.id;
this.manager = manager;
this.layer = new Konva.Layer({ id: entity.id });
this.layer = new Konva.Layer({
id: entity.id,
draggable: true,
dragDistance: 0,
});
// When a drag on the layer finishes, update the layer's position in state. During the drag, konva handles changing
// the position - we do not need to call this on the `dragmove` event.
this.layer.on('dragend', function (e) {
onPosChanged({ id: entity.id, x: Math.floor(e.target.x()), y: Math.floor(e.target.y()) }, 'regional_guidance');
});
this.group = new Konva.Group({ this.group = new Konva.Group({
id: getObjectGroupId(this.layer.id(), uuidv4()), id: getObjectGroupId(this.layer.id(), uuidv4()),
listening: false, listening: false,
}); });
this.objectsGroup = new Konva.Group({});
this.group.add(this.objectsGroup);
this.layer.add(this.group); this.layer.add(this.group);
this.transformer = new Konva.Transformer({
shouldOverdrawWholeArea: true,
draggable: true,
dragDistance: 0,
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
rotateEnabled: false,
flipEnabled: false,
});
this.transformer.on('transformend', () => {
this.manager.stateApi.onScaleChanged(
{ id: this.id, scale: this.group.scaleX(), x: this.group.x(), y: this.group.y() },
'regional_guidance'
);
});
this.transformer.on('dragend', () => {
this.manager.stateApi.onPosChanged({ id: this.id, x: this.group.x(), y: this.group.y() }, 'regional_guidance');
});
this.layer.add(this.transformer);
this.compositingRect = new Konva.Rect({ listening: false }); this.compositingRect = new Konva.Rect({ listening: false });
this.layer.add(this.compositingRect); this.group.add(this.compositingRect);
this.objects = new Map(); this.objects = new Map();
} }
@ -44,24 +63,16 @@ export class CanvasRegion {
this.layer.destroy(); this.layer.destroy();
} }
async render( async render(regionState: RegionEntity) {
regionState: RegionEntity,
selectedTool: Tool,
selectedEntityIdentifier: CanvasEntityIdentifier | null,
maskOpacity: number
) {
// Update the layer's position and listening state // Update the layer's position and listening state
this.layer.setAttrs({ this.group.setAttrs({
listening: selectedTool === 'move', // The layer only listens when using the move tool - otherwise the stage is handling mouse events x: regionState.x,
x: Math.floor(regionState.x), y: regionState.y,
y: Math.floor(regionState.y), scaleX: 1,
scaleY: 1,
}); });
// Convert the color to a string, stripping the alpha - the object group will handle opacity. let didDraw = false;
const rgbColor = rgbColorToString(regionState.fill);
// We use caching to handle "global" layer opacity, but caching is expensive and we should only do it when required.
let groupNeedsCache = false;
const objectIds = regionState.objects.map(mapId); const objectIds = regionState.objects.map(mapId);
// Destroy any objects that are no longer in state // Destroy any objects that are no longer in state
@ -69,7 +80,7 @@ export class CanvasRegion {
if (!objectIds.includes(object.id)) { if (!objectIds.includes(object.id)) {
this.objects.delete(object.id); this.objects.delete(object.id);
object.destroy(); object.destroy();
groupNeedsCache = true; didDraw = true;
} }
} }
@ -81,13 +92,12 @@ export class CanvasRegion {
if (!brushLine) { if (!brushLine) {
brushLine = new KonvaBrushLine(obj); brushLine = new KonvaBrushLine(obj);
this.objects.set(brushLine.id, brushLine); this.objects.set(brushLine.id, brushLine);
this.group.add(brushLine.konvaLineGroup); this.objectsGroup.add(brushLine.konvaLineGroup);
groupNeedsCache = true; didDraw = true;
} } else {
if (brushLine.update(obj)) {
if (obj.points.length !== brushLine.konvaLine.points().length) { didDraw = true;
brushLine.konvaLine.points(obj.points); }
groupNeedsCache = true;
} }
} else if (obj.type === 'eraser_line') { } else if (obj.type === 'eraser_line') {
let eraserLine = this.objects.get(obj.id); let eraserLine = this.objects.get(obj.id);
@ -96,13 +106,12 @@ export class CanvasRegion {
if (!eraserLine) { if (!eraserLine) {
eraserLine = new KonvaEraserLine(obj); eraserLine = new KonvaEraserLine(obj);
this.objects.set(eraserLine.id, eraserLine); this.objects.set(eraserLine.id, eraserLine);
this.group.add(eraserLine.konvaLineGroup); this.objectsGroup.add(eraserLine.konvaLineGroup);
groupNeedsCache = true; didDraw = true;
} } else {
if (eraserLine.update(obj)) {
if (obj.points.length !== eraserLine.konvaLine.points().length) { didDraw = true;
eraserLine.konvaLine.points(obj.points); }
groupNeedsCache = true;
} }
} else if (obj.type === 'rect_shape') { } else if (obj.type === 'rect_shape') {
let rect = this.objects.get(obj.id); let rect = this.objects.get(obj.id);
@ -111,8 +120,12 @@ export class CanvasRegion {
if (!rect) { if (!rect) {
rect = new KonvaRect(obj); rect = new KonvaRect(obj);
this.objects.set(rect.id, rect); this.objects.set(rect.id, rect);
this.group.add(rect.konvaRect); this.objectsGroup.add(rect.konvaRect);
groupNeedsCache = true; didDraw = true;
} else {
if (rect.update(obj)) {
didDraw = true;
}
} }
} }
} }
@ -120,92 +133,91 @@ export class CanvasRegion {
// Only update layer visibility if it has changed. // Only update layer visibility if it has changed.
if (this.layer.visible() !== regionState.isEnabled) { if (this.layer.visible() !== regionState.isEnabled) {
this.layer.visible(regionState.isEnabled); this.layer.visible(regionState.isEnabled);
groupNeedsCache = true;
} }
if (this.objects.size === 0) {
// No objects - clear the cache to reset the previous pixel data
this.group.clearCache();
return;
}
// We must clear the cache first so Konva will re-draw the group with the new compositing rect
if (this.group.isCached()) {
this.group.clearCache();
}
// The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work
this.group.opacity(1); this.group.opacity(1);
this.compositingRect.setAttrs({ if (didDraw) {
// The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already // Convert the color to a string, stripping the alpha - the object group will handle opacity.
...(!regionState.bboxNeedsUpdate && regionState.bbox ? regionState.bbox : getNodeBboxFast(this.layer)), const rgbColor = rgbColorToString(regionState.fill);
fill: rgbColor, const maskOpacity = this.manager.stateApi.getMaskOpacity();
opacity: maskOpacity,
// Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes)
globalCompositeOperation: 'source-in',
visible: true,
// This rect must always be on top of all other shapes
zIndex: this.objects.size + 1,
});
// const isSelected = selectedEntityIdentifier?.id === regionState.id; this.compositingRect.setAttrs({
// The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already
...getNodeBboxFast(this.objectsGroup),
fill: rgbColor,
opacity: maskOpacity,
// Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes)
globalCompositeOperation: 'source-in',
visible: true,
// This rect must always be on top of all other shapes
zIndex: this.objects.size + 1,
});
}
// /** this.updateGroup(didDraw);
// * When the group is selected, we use a rect of the selected preview color, composited over the shapes. This allows }
// * shapes to render as a "raster" layer with all pixels drawn at the same color and opacity.
// *
// * Without this special handling, each shape is drawn individually with the given opacity, atop the other shapes. The
// * effect is like if you have a Photoshop Group consisting of many shapes, each of which has the given opacity.
// * Overlapping shapes will have their colors blended together, and the final color is the result of all the shapes.
// *
// * Instead, with the special handling, the effect is as if you drew all the shapes at 100% opacity, flattened them to
// * a single raster image, and _then_ applied the 50% opacity.
// */
// if (isSelected && selectedTool !== 'move') {
// // We must clear the cache first so Konva will re-draw the group with the new compositing rect
// if (this.konvaObjectGroup.isCached()) {
// this.konvaObjectGroup.clearCache();
// }
// // The user is allowed to reduce mask opacity to 0, but we need the opacity for the compositing rect to work
// this.konvaObjectGroup.opacity(1);
// this.compositingRect.setAttrs({ updateGroup(didDraw: boolean) {
// // The rect should be the size of the layer - use the fast method if we don't have a pixel-perfect bbox already const isSelected = this.manager.stateApi.getIsSelected(this.id);
// ...(!regionState.bboxNeedsUpdate && regionState.bbox ? regionState.bbox : getLayerBboxFast(this.konvaLayer)), const selectedTool = this.manager.stateApi.getToolState().selected;
// fill: rgbColor,
// opacity: maskOpacity,
// // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes)
// globalCompositeOperation: 'source-in',
// visible: true,
// // This rect must always be on top of all other shapes
// zIndex: this.objects.size + 1,
// });
// } else {
// // The compositing rect should only be shown when the layer is selected.
// this.compositingRect.visible(false);
// // Cache only if needed - or if we are on this code path and _don't_ have a cache
// if (groupNeedsCache || !this.konvaObjectGroup.isCached()) {
// this.konvaObjectGroup.cache();
// }
// // Updating group opacity does not require re-caching
// this.konvaObjectGroup.opacity(maskOpacity);
// }
// const bboxRect = if (this.objects.size === 0) {
// regionMap.konvaLayer.findOne<Konva.Rect>(`.${LAYER_BBOX_NAME}`) ?? createBboxRect(rg, regionMap.konvaLayer); // If the layer is totally empty, reset the cache and bail out.
// if (rg.bbox) { this.layer.listening(false);
// const active = !rg.bboxNeedsUpdate && isSelected && tool === 'move'; this.transformer.nodes([]);
// bboxRect.setAttrs({ if (this.group.isCached()) {
// visible: active, this.group.clearCache();
// listening: active, }
// x: rg.bbox.x, return;
// y: rg.bbox.y, }
// width: rg.bbox.width,
// height: rg.bbox.height, if (isSelected && selectedTool === 'move') {
// stroke: isSelected ? BBOX_SELECTED_STROKE : '', // When the layer is selected and being moved, we should always cache it.
// }); // We should update the cache if we drew to the layer.
// } else { if (!this.group.isCached() || didDraw) {
// bboxRect.visible(false); this.group.cache();
// } }
// Activate the transformer
this.layer.listening(true);
this.transformer.nodes([this.group]);
this.transformer.forceUpdate();
return;
}
if (isSelected && selectedTool !== 'move') {
// If the layer is selected but not using the move tool, we don't want the layer to be listening.
this.layer.listening(false);
// The transformer also does not need to be active.
this.transformer.nodes([]);
if (isDrawingTool(selectedTool)) {
// We are using a drawing tool (brush, eraser, rect). These tools change the layer's rendered appearance, so we
// should never be cached.
if (this.group.isCached()) {
this.group.clearCache();
}
} else {
// We are using a non-drawing tool (move, view, bbox), so we should cache the layer.
// We should update the cache if we drew to the layer.
if (!this.group.isCached() || didDraw) {
this.group.cache();
}
}
return;
}
if (!isSelected) {
// Unselected layers should not be listening
this.layer.listening(false);
// The transformer also does not need to be active.
this.transformer.nodes([]);
// Update the layer's cache if it's not already cached or we drew to it.
if (!this.group.isCached() || didDraw) {
this.group.cache();
}
return;
}
} }
} }

View File

@ -37,6 +37,7 @@ import {
rgImageCacheChanged, rgImageCacheChanged,
rgLinePointAdded, rgLinePointAdded,
rgRectAdded, rgRectAdded,
rgScaled,
rgTranslated, rgTranslated,
toolBufferChanged, toolBufferChanged,
toolChanged, toolChanged,
@ -110,6 +111,8 @@ export const initializeRenderer = (
dispatch(layerScaled(arg)); dispatch(layerScaled(arg));
} else if (entityType === 'inpaint_mask') { } else if (entityType === 'inpaint_mask') {
dispatch(imScaled(arg)); dispatch(imScaled(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgScaled(arg));
} }
}; };
const onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => { const onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => {

View File

@ -280,6 +280,7 @@ export const {
rgEraserLineAdded, rgEraserLineAdded,
rgLinePointAdded, rgLinePointAdded,
rgRectAdded, rgRectAdded,
rgScaled,
// Compositing // Compositing
setInfillMethod, setInfillMethod,
setInfillTileSize, setInfillTileSize,

View File

@ -1,8 +1,8 @@
import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit'; import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/util/arrayUtils'; import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/util/arrayUtils';
import { getBrushLineId, getEraserLineId, getRectShapeId } from 'features/controlLayers/konva/naming'; import { getBrushLineId, getEraserLineId, getRectShapeId } from 'features/controlLayers/konva/naming';
import type { CanvasV2State, CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; import type { CanvasV2State, CLIPVisionModelV2, IPMethodV2, ScaleChangedArg } from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims,RGBA_RED } from 'features/controlLayers/store/types'; import { imageDTOToImageObject, imageDTOToImageWithDims, RGBA_RED } from 'features/controlLayers/store/types';
import { zModelIdentifierField } from 'features/nodes/types/common'; import { zModelIdentifierField } from 'features/nodes/types/common';
import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
@ -107,6 +107,31 @@ export const regionsReducers = {
rg.y = y; rg.y = y;
} }
}, },
rgScaled: (state, action: PayloadAction<ScaleChangedArg>) => {
const { id, scale, x, y } = action.payload;
const rg = selectRG(state, id);
if (!rg) {
return;
}
for (const obj of rg.objects) {
if (obj.type === 'brush_line') {
obj.points = obj.points.map((point) => point * scale);
obj.strokeWidth *= scale;
} else if (obj.type === 'eraser_line') {
obj.points = obj.points.map((point) => point * scale);
obj.strokeWidth *= scale;
} else if (obj.type === 'rect_shape') {
obj.x *= scale;
obj.y *= scale;
obj.height *= scale;
obj.width *= scale;
}
}
rg.x = x;
rg.y = y;
rg.bboxNeedsUpdate = true;
state.layers.imageCache = null;
},
rgBboxChanged: (state, action: PayloadAction<{ id: string; bbox: IRect | null }>) => { rgBboxChanged: (state, action: PayloadAction<{ id: string; bbox: IRect | null }>) => {
const { id, bbox } = action.payload; const { id, bbox } = action.payload;
const rg = selectRG(state, id); const rg = selectRG(state, id);