feat(ui): organize konva state and files

This commit is contained in:
psychedelicious 2024-07-04 13:15:14 +10:00
parent 0b5d20c9f0
commit 47b94d563c
20 changed files with 566 additions and 751 deletions

View File

@ -1,12 +1,16 @@
import { Flex } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { $isDebugging } from 'app/store/nanostores/isDebugging';
import { useAppStore } from 'app/store/storeHooks';
import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
import { initializeRenderer } from 'features/controlLayers/konva/renderers/renderer';
import { KonvaNodeManager, setNodeManager } from 'features/controlLayers/konva/nodeManager';
import Konva from 'konva';
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
import { v4 as uuidv4 } from 'uuid';
const log = logger('konva');
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
Konva.showWarnings = false;
@ -15,7 +19,25 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null,
const dpr = useDevicePixelRatio({ round: false });
useLayoutEffect(() => {
const cleanup = initializeRenderer(store, stage, container);
/**
* Logs a message to the console if debugging is enabled.
*/
const logIfDebugging = (message: string) => {
if ($isDebugging.get()) {
log.debug(message);
}
};
logIfDebugging('Initializing renderer');
if (!container) {
// Nothing to clean up
logIfDebugging('No stage container, skipping initialization');
return () => {};
}
const manager = new KonvaNodeManager(stage, container, store, logIfDebugging);
setNodeManager(manager);
const cleanup = manager.initialize();
return cleanup;
}, [asPreview, container, stage, store]);

View File

@ -1,4 +1,5 @@
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import Konva from 'konva';
const baseGridLineColor = getArbitraryBaseColor(27);
@ -30,19 +31,21 @@ const getGridSpacing = (scale: number): number => {
export class CanvasBackground {
layer: Konva.Layer;
manager: KonvaNodeManager;
constructor() {
constructor(manager: KonvaNodeManager) {
this.manager = manager;
this.layer = new Konva.Layer({ listening: false });
}
renderBackground(stage: Konva.Stage): void {
renderBackground() {
this.layer.zIndex(0);
const scale = stage.scaleX();
const scale = this.manager.stage.scaleX();
const gridSpacing = getGridSpacing(scale);
const x = stage.x();
const y = stage.y();
const width = stage.width();
const height = stage.height();
const x = this.manager.stage.x();
const y = this.manager.stage.y();
const width = this.manager.stage.width();
const height = this.manager.stage.height();
const stageRect = {
x1: 0,
y1: 0,

View File

@ -2,19 +2,19 @@ import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMult
import {
PREVIEW_GENERATION_BBOX_DUMMY_RECT,
PREVIEW_GENERATION_BBOX_GROUP,
PREVIEW_GENERATION_BBOX_TRANSFORMER
PREVIEW_GENERATION_BBOX_TRANSFORMER,
} from 'features/controlLayers/konva/naming';
import type { CanvasV2State } from 'features/controlLayers/store/types';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import Konva from 'konva';
import type { IRect } from 'konva/lib/types';
import { atom } from 'nanostores';
import { assert } from 'tsafe';
export class CanvasBbox {
group: Konva.Group;
rect: Konva.Rect;
transformer: Konva.Transformer;
manager: KonvaNodeManager;
ALL_ANCHORS: string[] = [
'top-left',
@ -29,17 +29,11 @@ export class CanvasBbox {
CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
NO_ANCHORS: string[] = [];
constructor(
getBbox: () => IRect,
onBboxTransformed: (bbox: IRect) => void,
getShiftKey: () => boolean,
getCtrlKey: () => boolean,
getMetaKey: () => boolean,
getAltKey: () => boolean
) {
constructor(manager: KonvaNodeManager) {
this.manager = manager;
// Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when
// transforming the bbox.
const bbox = getBbox();
const bbox = this.manager.stateApi.getBbox();
const $aspectRatioBuffer = atom(bbox.width / bbox.height);
// Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully
@ -50,11 +44,11 @@ export class CanvasBbox {
listening: false,
strokeEnabled: false,
draggable: true,
...getBbox(),
...this.manager.stateApi.getBbox(),
});
this.rect.on('dragmove', () => {
const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64;
const oldBbox = getBbox();
const gridSize = this.manager.stateApi.getCtrlKey() || this.manager.stateApi.getMetaKey() ? 8 : 64;
const oldBbox = this.manager.stateApi.getBbox();
const newBbox: IRect = {
...oldBbox,
x: roundToMultiple(this.rect.x(), gridSize),
@ -62,7 +56,7 @@ export class CanvasBbox {
};
this.rect.setAttrs(newBbox);
if (oldBbox.x !== newBbox.x || oldBbox.y !== newBbox.y) {
onBboxTransformed(newBbox);
this.manager.stateApi.onBboxTransformed(newBbox);
}
});
@ -104,7 +98,7 @@ export class CanvasBbox {
assert(stage, 'Stage must exist');
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
const gridSize = getCtrlKey() || getMetaKey() ? 8 : 64;
const gridSize = this.manager.stateApi.getCtrlKey() || this.manager.stateApi.getMetaKey() ? 8 : 64;
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
const scaledGridSize = gridSize * stage.scaleX();
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
@ -129,10 +123,10 @@ export class CanvasBbox {
return;
}
const alt = getAltKey();
const ctrl = getCtrlKey();
const meta = getMetaKey();
const shift = getShiftKey();
const alt = this.manager.stateApi.getAltKey();
const ctrl = this.manager.stateApi.getCtrlKey();
const meta = this.manager.stateApi.getMetaKey();
const shift = this.manager.stateApi.getShiftKey();
// Grid size depends on the modifier keys
let gridSize = ctrl || meta ? 8 : 64;
@ -141,7 +135,7 @@ export class CanvasBbox {
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
if (getAltKey()) {
if (this.manager.stateApi.getAltKey()) {
gridSize = gridSize * 2;
}
@ -196,7 +190,7 @@ export class CanvasBbox {
this.rect.setAttrs({ ...bbox, scaleX: 1, scaleY: 1 });
// Update the bbox in internal state.
onBboxTransformed(bbox);
this.manager.stateApi.onBboxTransformed(bbox);
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
// a transform, get the right aspect ratio, then hold shift to lock it in.
@ -217,7 +211,10 @@ export class CanvasBbox {
this.group.add(this.transformer);
}
render(bbox: CanvasV2State['bbox'], toolState: CanvasV2State['tool']) {
render() {
const bbox = this.manager.stateApi.getBbox();
const toolState = this.manager.stateApi.getToolState();
this.group.listening(toolState.selected === 'bbox');
this.rect.setAttrs({
x: bbox.x,

View File

@ -1,6 +1,6 @@
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
import { DOCUMENT_FIT_PADDING_PX } from 'features/controlLayers/konva/constants';
import type { CanvasV2State, StageAttrs } from 'features/controlLayers/store/types';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import Konva from 'konva';
export class CanvasDocumentSizeOverlay {
@ -8,8 +8,10 @@ export class CanvasDocumentSizeOverlay {
outerRect: Konva.Rect;
innerRect: Konva.Rect;
padding: number;
manager: KonvaNodeManager;
constructor(padding?: number) {
constructor(manager: KonvaNodeManager, padding?: number) {
this.manager = manager;
this.padding = padding ?? DOCUMENT_FIT_PADDING_PX;
this.group = new Konva.Group({ id: 'document_overlay_group', listening: false });
this.outerRect = new Konva.Rect({
@ -28,14 +30,15 @@ export class CanvasDocumentSizeOverlay {
this.group.add(this.innerRect);
}
render(stage: Konva.Stage, document: CanvasV2State['document']) {
render() {
const document = this.manager.stateApi.getDocument();
this.group.zIndex(0);
const x = stage.x();
const y = stage.y();
const width = stage.width();
const height = stage.height();
const scale = stage.scaleX();
const x = this.manager.stage.x();
const y = this.manager.stage.y();
const width = this.manager.stage.width();
const height = this.manager.stage.height();
const scale = this.manager.stage.scaleX();
this.outerRect.setAttrs({
offsetX: x / scale,
@ -52,16 +55,18 @@ export class CanvasDocumentSizeOverlay {
});
}
fitToStage(stage: Konva.Stage, document: CanvasV2State['document'], setStageAttrs: (attrs: StageAttrs) => void) {
fitToStage() {
const document = this.manager.stateApi.getDocument();
// Fit & center the document on the stage
const width = stage.width();
const height = stage.height();
const width = this.manager.stage.width();
const height = this.manager.stage.height();
const docWidthWithBuffer = document.width + this.padding * 2;
const docHeightWithBuffer = document.height + this.padding * 2;
const scale = Math.min(Math.min(width / docWidthWithBuffer, height / docHeightWithBuffer), 1);
const x = (width - docWidthWithBuffer * scale) / 2 + this.padding * scale;
const y = (height - docHeightWithBuffer * scale) / 2 + this.padding * scale;
stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale });
setStageAttrs({ x, y, width, height, scale });
this.manager.stage.setAttrs({ x, y, width, height, scaleX: scale, scaleY: scale });
this.manager.stateApi.setStageAttrs({ x, y, width, height, scale });
}
}

View File

@ -5,7 +5,7 @@ import {
RASTER_LAYER_OBJECT_GROUP_NAME,
RG_LAYER_OBJECT_GROUP_NAME,
} from 'features/controlLayers/konva/naming';
import { createBboxRect } from 'features/controlLayers/konva/renderers/objects';
import { createBboxRect } from 'features/controlLayers/konva/objects';
import { imageDataToDataURL } from 'features/controlLayers/konva/util';
import type {
BboxChangedArg,

View File

@ -128,7 +128,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
//#region mouseenter
stage.on('mouseenter', () => {
manager.renderToolPreview();
manager.preview.tool.render();
});
//#region mousedown
@ -249,7 +249,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
setLastAddedPoint(pos);
}
}
manager.renderToolPreview();
manager.preview.tool.render();
});
//#region mouseup
@ -288,7 +288,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
setLastMouseDownPos(null);
}
manager.renderToolPreview();
manager.preview.tool.render();
});
//#region mousemove
@ -394,7 +394,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
}
}
}
manager.renderToolPreview();
manager.preview.tool.render();
});
//#region mouseleave
@ -423,7 +423,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
}
}
manager.renderToolPreview();
manager.preview.tool.render();
});
//#region wheel
@ -464,11 +464,11 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
stage.scaleY(newScale);
stage.position(newPos);
setStageAttrs({ ...newPos, width: stage.width(), height: stage.height(), scale: newScale });
manager.renderBackground();
manager.renderDocumentSizeOverlay();
manager.preview.tool.render();
manager.preview.documentSizeOverlay.render();
}
}
manager.renderToolPreview();
manager.preview.tool.render();
});
//#region dragmove
@ -480,9 +480,9 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
height: stage.height(),
scale: stage.scaleX(),
});
manager.renderBackground();
manager.renderDocumentSizeOverlay();
manager.renderToolPreview();
manager.preview.tool.render();
manager.preview.documentSizeOverlay.render();
manager.preview.tool.render();
});
//#region dragend
@ -495,7 +495,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
height: stage.height(),
scale: stage.scaleX(),
});
manager.renderToolPreview();
manager.preview.tool.render();
});
//#region key
@ -520,11 +520,12 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
} else if (e.key === 'r') {
setLastCursorPos(null);
setLastMouseDownPos(null);
manager.fitDocument();
manager.renderBackground();
manager.renderDocumentSizeOverlay();
manager.preview.documentSizeOverlay.fitToStage();
manager.preview.tool.render();
manager.preview.documentSizeOverlay.render();
}
manager.renderToolPreview();
manager.preview.tool.render();
};
window.addEventListener('keydown', onKeyDown);
@ -542,7 +543,7 @@ export const setStageEventHandlers = (manager: KonvaNodeManager): (() => void) =
setToolBuffer(null);
setSpaceKey(false);
}
manager.renderToolPreview();
manager.preview.tool.render();
};
window.addEventListener('keyup', onKeyUp);

View File

@ -1,8 +1,8 @@
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { getObjectGroupId } from 'features/controlLayers/konva/naming';
import { getNodeBboxFast } from 'features/controlLayers/konva/entityBbox';
import { getObjectGroupId,INPAINT_MASK_LAYER_ID } from 'features/controlLayers/konva/naming';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
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/objects';
import { mapId } from 'features/controlLayers/konva/util';
import { type InpaintMaskEntity, isDrawingTool } from 'features/controlLayers/store/types';
import Konva from 'konva';
@ -19,10 +19,10 @@ export class CanvasInpaintMask {
transformer: Konva.Transformer;
objects: Map<string, KonvaBrushLine | KonvaEraserLine | KonvaRect>;
constructor(entity: InpaintMaskEntity, manager: KonvaNodeManager) {
this.id = entity.id;
constructor(manager: KonvaNodeManager) {
this.id = INPAINT_MASK_LAYER_ID;
this.manager = manager;
this.layer = new Konva.Layer({ id: entity.id });
this.layer = new Konva.Layer({ id: INPAINT_MASK_LAYER_ID });
this.group = new Konva.Group({
id: getObjectGroupId(this.layer.id(), uuidv4()),

View File

@ -1,6 +1,6 @@
import { getObjectGroupId } from 'features/controlLayers/konva/naming';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import { KonvaBrushLine, KonvaEraserLine, KonvaImage, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
import { KonvaBrushLine, KonvaEraserLine, KonvaImage, KonvaRect } from 'features/controlLayers/konva/objects';
import { mapId } from 'features/controlLayers/konva/util';
import { isDrawingTool, type LayerEntity } from 'features/controlLayers/store/types';
import Konva from 'konva';

View File

@ -39,11 +39,11 @@ export const RASTER_LAYER_ERASER_LINE_NAME = `${RASTER_LAYER_NAME}.eraser_line`;
export const RASTER_LAYER_RECT_SHAPE_NAME = `${RASTER_LAYER_NAME}.rect_shape`;
export const RASTER_LAYER_IMAGE_NAME = `${RASTER_LAYER_NAME}.image`;
export const INPAINT_MASK_LAYER_NAME = 'inpaint_mask_layer';
export const INPAINT_MASK_LAYER_OBJECT_GROUP_NAME = `${INPAINT_MASK_LAYER_NAME}.object_group`;
export const INPAINT_MASK_LAYER_BRUSH_LINE_NAME = `${INPAINT_MASK_LAYER_NAME}.brush_line`;
export const INPAINT_MASK_LAYER_ERASER_LINE_NAME = `${INPAINT_MASK_LAYER_NAME}.eraser_line`;
export const INPAINT_MASK_LAYER_RECT_SHAPE_NAME = `${INPAINT_MASK_LAYER_NAME}.rect_shape`;
export const INPAINT_MASK_LAYER_ID = 'inpaint_mask_layer';
export const INPAINT_MASK_LAYER_OBJECT_GROUP_NAME = `${INPAINT_MASK_LAYER_ID}.object_group`;
export const INPAINT_MASK_LAYER_BRUSH_LINE_NAME = `${INPAINT_MASK_LAYER_ID}.brush_line`;
export const INPAINT_MASK_LAYER_ERASER_LINE_NAME = `${INPAINT_MASK_LAYER_ID}.eraser_line`;
export const INPAINT_MASK_LAYER_RECT_SHAPE_NAME = `${INPAINT_MASK_LAYER_ID}.rect_shape`;
export const BACKGROUND_LAYER_ID = 'background_layer';

View File

@ -1,89 +1,28 @@
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { getImageDataTransparency } from 'common/util/arrayBuffer';
import { CanvasBackground } from 'features/controlLayers/konva/renderers/background';
import { CanvasPreview } from 'features/controlLayers/konva/renderers/preview';
import { CanvasBackground } from 'features/controlLayers/konva/background';
import { setStageEventHandlers } from 'features/controlLayers/konva/events';
import { CanvasPreview } from 'features/controlLayers/konva/preview';
import { konvaNodeToBlob, konvaNodeToImageData, previewBlob } from 'features/controlLayers/konva/util';
import type {
BrushLineAddedArg,
CanvasEntity,
CanvasV2State,
EraserLineAddedArg,
GenerationMode,
PointAddedToLineArg,
PosChangedArg,
Rect,
RectShapeAddedArg,
RgbaColor,
ScaleChangedArg,
StageAttrs,
Tool,
} from 'features/controlLayers/store/types';
import { $lastProgressEvent, $shouldShowStagedImage } from 'features/controlLayers/store/canvasV2Slice';
import type { CanvasV2State, GenerationMode, Rect } from 'features/controlLayers/store/types';
import { isValidLayer } from 'features/nodes/util/graph/generation/addLayers';
import type Konva from 'konva';
import type { Vector2d } from 'konva/lib/types';
import { atom } from 'nanostores';
import { getImageDTO as defaultGetImageDTO, uploadImage as defaultUploadImage } from 'services/api/endpoints/images';
import type { ImageCategory, ImageDTO } from 'services/api/types';
import type { InvocationDenoiseProgressEvent } from 'services/events/types';
import { assert } from 'tsafe';
import { CanvasBbox } from './renderers/bbox';
import { CanvasControlAdapter } from './renderers/controlAdapters';
import { CanvasDocumentSizeOverlay } from './renderers/documentSizeOverlay';
import { CanvasInpaintMask } from './renderers/inpaintMask';
import { CanvasLayer } from './renderers/layers';
import { CanvasRegion } from './renderers/regions';
import { CanvasStagingArea } from './renderers/stagingArea';
import { CanvasTool } from './renderers/tool';
export type StateApi = {
getToolState: () => CanvasV2State['tool'];
getCurrentFill: () => RgbaColor;
setTool: (tool: Tool) => void;
setToolBuffer: (tool: Tool | null) => void;
getIsDrawing: () => boolean;
setIsDrawing: (isDrawing: boolean) => void;
getIsMouseDown: () => boolean;
setIsMouseDown: (isMouseDown: boolean) => void;
getLastMouseDownPos: () => Vector2d | null;
setLastMouseDownPos: (pos: Vector2d | null) => void;
getLastCursorPos: () => Vector2d | null;
setLastCursorPos: (pos: Vector2d | null) => void;
getLastAddedPoint: () => Vector2d | null;
setLastAddedPoint: (pos: Vector2d | null) => void;
setStageAttrs: (attrs: StageAttrs) => void;
getSelectedEntity: () => CanvasEntity | null;
getSpaceKey: () => boolean;
setSpaceKey: (val: boolean) => void;
getShouldShowStagedImage: () => boolean;
getBbox: () => CanvasV2State['bbox'];
getSettings: () => CanvasV2State['settings'];
onBrushLineAdded: (arg: BrushLineAddedArg, entityType: CanvasEntity['type']) => void;
onEraserLineAdded: (arg: EraserLineAddedArg, entityType: CanvasEntity['type']) => void;
onPointAddedToLine: (arg: PointAddedToLineArg, entityType: CanvasEntity['type']) => void;
onRectShapeAdded: (arg: RectShapeAddedArg, entityType: CanvasEntity['type']) => void;
onBrushWidthChanged: (size: number) => void;
onEraserWidthChanged: (size: number) => void;
getMaskOpacity: () => number;
getIsSelected: (id: string) => boolean;
onScaleChanged: (arg: ScaleChangedArg, entityType: CanvasEntity['type']) => void;
onPosChanged: (arg: PosChangedArg, entityType: CanvasEntity['type']) => void;
onBboxTransformed: (bbox: Rect) => void;
getShiftKey: () => boolean;
getCtrlKey: () => boolean;
getMetaKey: () => boolean;
getAltKey: () => boolean;
getDocument: () => CanvasV2State['document'];
getLayersState: () => CanvasV2State['layers'];
getControlAdaptersState: () => CanvasV2State['controlAdapters'];
getRegionsState: () => CanvasV2State['regions'];
getInpaintMaskState: () => CanvasV2State['inpaintMask'];
getStagingAreaState: () => CanvasV2State['stagingArea'];
getLastProgressEvent: () => InvocationDenoiseProgressEvent | null;
resetLastProgressEvent: () => void;
onInpaintMaskImageCached: (imageDTO: ImageDTO) => void;
onRegionMaskImageCached: (id: string, imageDTO: ImageDTO) => void;
onLayerImageCached: (imageDTO: ImageDTO) => void;
};
import { CanvasBbox } from './bbox';
import { CanvasControlAdapter } from './controlAdapters';
import { CanvasDocumentSizeOverlay } from './documentSizeOverlay';
import { CanvasInpaintMask } from './inpaintMask';
import { CanvasLayer } from './layers';
import { CanvasRegion } from './regions';
import { CanvasStagingArea } from './stagingArea';
import { StateApi } from './stateApi';
import { CanvasTool } from './tool';
type Util = {
getImageDTO: (imageName: string) => Promise<ImageDTO | null>;
@ -116,41 +55,44 @@ export class KonvaNodeManager {
stateApi: StateApi;
preview: CanvasPreview;
background: CanvasBackground;
private store: Store<RootState>;
private isFirstRender: boolean;
private prevState: CanvasV2State;
private log: (message: string) => void;
constructor(
stage: Konva.Stage,
container: HTMLDivElement,
stateApi: StateApi,
store: Store<RootState>,
log: (message: string) => void,
getImageDTO: Util['getImageDTO'] = defaultGetImageDTO,
uploadImage: Util['uploadImage'] = defaultUploadImage
) {
this.log = log;
this.stage = stage;
this.container = container;
this.stateApi = stateApi;
this.store = store;
this.stateApi = new StateApi(this.store, this.log);
this.prevState = this.stateApi.getState();
this.isFirstRender = true;
this.util = {
getImageDTO,
uploadImage,
};
this.preview = new CanvasPreview(
new CanvasBbox(
this.stateApi.getBbox,
this.stateApi.onBboxTransformed,
this.stateApi.getShiftKey,
this.stateApi.getCtrlKey,
this.stateApi.getMetaKey,
this.stateApi.getAltKey
),
new CanvasTool(),
new CanvasDocumentSizeOverlay(),
new CanvasStagingArea()
new CanvasBbox(this),
new CanvasTool(this),
new CanvasDocumentSizeOverlay(this),
new CanvasStagingArea(this)
);
this.stage.add(this.preview.layer);
this.background = new CanvasBackground();
this.background = new CanvasBackground(this);
this.stage.add(this.background.layer);
this.inpaintMask = new CanvasInpaintMask(this.stateApi.getInpaintMaskState(), this);
this.inpaintMask = new CanvasInpaintMask(this);
this.stage.add(this.inpaintMask.layer);
this.layers = new Map();
@ -247,46 +189,6 @@ export class KonvaNodeManager {
this.preview.layer.zIndex(++zIndex);
}
renderDocumentSizeOverlay() {
this.preview.documentSizeOverlay.render(this.stage, this.stateApi.getDocument());
}
renderBbox() {
this.preview.bbox.render(this.stateApi.getBbox(), this.stateApi.getToolState());
}
renderToolPreview() {
this.preview.tool.render(
this.stage,
1, // TODO(psyche): this should be renderable entity count
this.stateApi.getToolState(),
this.stateApi.getCurrentFill(),
this.stateApi.getSelectedEntity(),
this.stateApi.getLastCursorPos(),
this.stateApi.getLastMouseDownPos(),
this.stateApi.getIsDrawing(),
this.stateApi.getIsMouseDown()
);
}
renderBackground() {
this.background.renderBackground(this.stage);
}
renderStagingArea() {
this.preview.stagingArea.render(
this.stateApi.getStagingAreaState(),
this.stateApi.getBbox(),
this.stateApi.getShouldShowStagedImage(),
this.stateApi.getLastProgressEvent(),
this.stateApi.resetLastProgressEvent
);
}
fitDocument() {
this.preview.documentSizeOverlay.fitToStage(this.stage, this.stateApi.getDocument(), this.stateApi.setStageAttrs);
}
fitStageToContainer() {
this.stage.width(this.container.offsetWidth);
this.stage.height(this.container.offsetHeight);
@ -297,10 +199,150 @@ export class KonvaNodeManager {
height: this.stage.height(),
scale: this.stage.scaleX(),
});
this.renderBackground();
this.renderDocumentSizeOverlay();
this.background.renderBackground();
this.preview.documentSizeOverlay.render();
}
render = async () => {
const state = this.stateApi.getState();
if (this.prevState === state && !this.isFirstRender) {
this.log('No changes detected, skipping render');
return;
}
if (
this.isFirstRender ||
state.layers.entities !== this.prevState.layers.entities ||
state.tool.selected !== this.prevState.tool.selected ||
state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id
) {
this.log('Rendering layers');
this.renderLayers();
}
if (
this.isFirstRender ||
state.regions.entities !== this.prevState.regions.entities ||
state.settings.maskOpacity !== this.prevState.settings.maskOpacity ||
state.tool.selected !== this.prevState.tool.selected ||
state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id
) {
this.log('Rendering regions');
this.renderRegions();
}
if (
this.isFirstRender ||
state.inpaintMask !== this.prevState.inpaintMask ||
state.settings.maskOpacity !== this.prevState.settings.maskOpacity ||
state.tool.selected !== this.prevState.tool.selected ||
state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id
) {
this.log('Rendering inpaint mask');
this.renderInpaintMask();
}
if (
this.isFirstRender ||
state.controlAdapters.entities !== this.prevState.controlAdapters.entities ||
state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id
) {
this.log('Rendering control adapters');
this.renderControlAdapters();
}
if (this.isFirstRender || state.document !== this.prevState.document) {
this.log('Rendering document bounds overlay');
this.preview.documentSizeOverlay.render();
}
if (
this.isFirstRender ||
state.bbox !== this.prevState.bbox ||
state.tool.selected !== this.prevState.tool.selected
) {
this.log('Rendering generation bbox');
this.preview.bbox.render();
}
if (
this.isFirstRender ||
state.layers !== this.prevState.layers ||
state.controlAdapters !== this.prevState.controlAdapters ||
state.regions !== this.prevState.regions
) {
// this.log('Updating entity bboxes');
// debouncedUpdateBboxes(stage, canvasV2.layers, canvasV2.controlAdapters, canvasV2.regions, onBboxChanged);
}
if (this.isFirstRender || state.stagingArea !== this.prevState.stagingArea) {
this.log('Rendering staging area');
this.preview.stagingArea.render();
}
if (
this.isFirstRender ||
state.layers.entities !== this.prevState.layers.entities ||
state.controlAdapters.entities !== this.prevState.controlAdapters.entities ||
state.regions.entities !== this.prevState.regions.entities ||
state.inpaintMask !== this.prevState.inpaintMask ||
state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id
) {
this.log('Arranging entities');
this.arrangeEntities();
}
this.prevState = state;
if (this.isFirstRender) {
this.isFirstRender = false;
}
};
initialize = () => {
this.log('Initializing renderer');
this.stage.container(this.container);
const cleanupListeners = setStageEventHandlers(this);
// We can use a resize observer to ensure the stage always fits the container. We also need to re-render the bg and
// document bounds overlay when the stage is resized.
const resizeObserver = new ResizeObserver(this.fitStageToContainer.bind(this));
resizeObserver.observe(this.container);
this.fitStageToContainer();
const unsubscribeRenderer = this.store.subscribe(this.render);
// When we this flag, we need to render the staging area
$shouldShowStagedImage.subscribe((shouldShowStagedImage, prevShouldShowStagedImage) => {
this.log('Rendering staging area');
if (shouldShowStagedImage !== prevShouldShowStagedImage) {
this.preview.stagingArea.render();
}
});
$lastProgressEvent.subscribe(() => {
this.log('Rendering staging area');
this.preview.stagingArea.render();
});
this.log('First render of konva stage');
// On first render, the document should be fit to the stage.
this.preview.documentSizeOverlay.render();
this.preview.documentSizeOverlay.fitToStage();
this.preview.tool.render();
this.render();
return () => {
this.log('Cleaning up konva renderer');
unsubscribeRenderer();
cleanupListeners();
$shouldShowStagedImage.off();
resizeObserver.disconnect();
};
};
getInpaintMaskLayerClone(): Konva.Layer {
const layerClone = this.inpaintMask.layer.clone();
const objectGroupClone = this.inpaintMask.group.clone();

View File

@ -1,13 +1,10 @@
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { getObjectGroupId } from 'features/controlLayers/konva/naming';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import { getNodeBboxFast } from 'features/controlLayers/konva/renderers/entityBbox';
import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/renderers/objects';
import { getNodeBboxFast } from 'features/controlLayers/konva/entityBbox';
import { KonvaBrushLine, KonvaEraserLine, KonvaRect } from 'features/controlLayers/konva/objects';
import { mapId } from 'features/controlLayers/konva/util';
import {
isDrawingTool,
type RegionEntity,
} from 'features/controlLayers/store/types';
import { isDrawingTool, type RegionEntity } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { assert } from 'tsafe';
import { v4 as uuidv4 } from 'uuid';

View File

@ -1,499 +0,0 @@
import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library';
import type { Store } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { $isDebugging } from 'app/store/nanostores/isDebugging';
import type { RootState } from 'app/store/store';
import { setStageEventHandlers } from 'features/controlLayers/konva/events';
import { KonvaNodeManager, setNodeManager } from 'features/controlLayers/konva/nodeManager';
import { updateBboxes } from 'features/controlLayers/konva/renderers/entityBbox';
import {
$lastProgressEvent,
$shouldShowStagedImage,
$stageAttrs,
bboxChanged,
brushWidthChanged,
caBboxChanged,
caTranslated,
eraserWidthChanged,
imBboxChanged,
imBrushLineAdded,
imEraserLineAdded,
imImageCacheChanged,
imLinePointAdded,
imRectAdded,
imScaled,
imTranslated,
layerBboxChanged,
layerBrushLineAdded,
layerEraserLineAdded,
layerImageCacheChanged,
layerLinePointAdded,
layerRectAdded,
layerScaled,
layerTranslated,
rgBboxChanged,
rgBrushLineAdded,
rgEraserLineAdded,
rgImageCacheChanged,
rgLinePointAdded,
rgRectAdded,
rgScaled,
rgTranslated,
toolBufferChanged,
toolChanged,
} from 'features/controlLayers/store/canvasV2Slice';
import type {
BboxChangedArg,
BrushLineAddedArg,
CanvasEntity,
CanvasV2State,
EraserLineAddedArg,
PointAddedToLineArg,
PosChangedArg,
RectShapeAddedArg,
ScaleChangedArg,
Tool,
} from 'features/controlLayers/store/types';
import type Konva from 'konva';
import type { IRect, Vector2d } from 'konva/lib/types';
import { debounce } from 'lodash-es';
import type { RgbaColor } from 'react-colorful';
import type { ImageDTO } from 'services/api/types';
/**
* Initializes the canvas renderer. It subscribes to the redux store and listens for changes directly, bypassing the
* react rendering cycle entirely, improving canvas performance.
* @param store The redux store
* @param stage The konva stage
* @param container The stage's target container element
* @returns A cleanup function
*/
export const initializeRenderer = (
store: Store<RootState>,
stage: Konva.Stage,
container: HTMLDivElement | null
): (() => void) => {
const _log = logger('konva');
/**
* Logs a message to the console if debugging is enabled.
*/
const logIfDebugging = (message: string) => {
if ($isDebugging.get()) {
_log.debug(message);
}
};
logIfDebugging('Initializing renderer');
if (!container) {
// Nothing to clean up
logIfDebugging('No stage container, skipping initialization');
return () => {};
}
stage.container(container);
// Set up callbacks for various events
const onPosChanged = (arg: PosChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('onPosChanged');
if (entityType === 'layer') {
dispatch(layerTranslated(arg));
} else if (entityType === 'control_adapter') {
dispatch(caTranslated(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgTranslated(arg));
} else if (entityType === 'inpaint_mask') {
dispatch(imTranslated(arg));
}
};
const onScaleChanged = (arg: ScaleChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('onScaleChanged');
if (entityType === 'layer') {
dispatch(layerScaled(arg));
} else if (entityType === 'inpaint_mask') {
dispatch(imScaled(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgScaled(arg));
}
};
const onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Entity bbox changed');
if (entityType === 'layer') {
dispatch(layerBboxChanged(arg));
} else if (entityType === 'control_adapter') {
dispatch(caBboxChanged(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgBboxChanged(arg));
} else if (entityType === 'inpaint_mask') {
dispatch(imBboxChanged(arg));
}
};
const onBrushLineAdded = (arg: BrushLineAddedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Brush line added');
if (entityType === 'layer') {
dispatch(layerBrushLineAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgBrushLineAdded(arg));
} else if (entityType === 'inpaint_mask') {
dispatch(imBrushLineAdded(arg));
}
};
const onEraserLineAdded = (arg: EraserLineAddedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Eraser line added');
if (entityType === 'layer') {
dispatch(layerEraserLineAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgEraserLineAdded(arg));
} else if (entityType === 'inpaint_mask') {
dispatch(imEraserLineAdded(arg));
}
};
const onPointAddedToLine = (arg: PointAddedToLineArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Point added to line');
if (entityType === 'layer') {
dispatch(layerLinePointAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgLinePointAdded(arg));
} else if (entityType === 'inpaint_mask') {
dispatch(imLinePointAdded(arg));
}
};
const onRectShapeAdded = (arg: RectShapeAddedArg, entityType: CanvasEntity['type']) => {
logIfDebugging('Rect shape added');
if (entityType === 'layer') {
dispatch(layerRectAdded(arg));
} else if (entityType === 'regional_guidance') {
dispatch(rgRectAdded(arg));
} else if (entityType === 'inpaint_mask') {
dispatch(imRectAdded(arg));
}
};
const onBboxTransformed = (bbox: IRect) => {
logIfDebugging('Generation bbox transformed');
dispatch(bboxChanged(bbox));
};
const onBrushWidthChanged = (width: number) => {
logIfDebugging('Brush width changed');
dispatch(brushWidthChanged(width));
};
const onEraserWidthChanged = (width: number) => {
logIfDebugging('Eraser width changed');
dispatch(eraserWidthChanged(width));
};
const onRegionMaskImageCached = (id: string, imageDTO: ImageDTO) => {
logIfDebugging('Region mask image cached');
dispatch(rgImageCacheChanged({ id, imageDTO }));
};
const onInpaintMaskImageCached = (imageDTO: ImageDTO) => {
logIfDebugging('Inpaint mask image cached');
dispatch(imImageCacheChanged({ imageDTO }));
};
const onLayerImageCached = (imageDTO: ImageDTO) => {
logIfDebugging('Layer image cached');
dispatch(layerImageCacheChanged({ imageDTO }));
};
const setTool = (tool: Tool) => {
logIfDebugging('Tool selection changed');
dispatch(toolChanged(tool));
};
const setToolBuffer = (toolBuffer: Tool | null) => {
logIfDebugging('Tool buffer changed');
dispatch(toolBufferChanged(toolBuffer));
};
const selectSelectedEntity = (canvasV2: CanvasV2State): CanvasEntity | null => {
const identifier = canvasV2.selectedEntityIdentifier;
let selectedEntity: CanvasEntity | null = null;
if (!identifier) {
selectedEntity = null;
} else if (identifier.type === 'layer') {
selectedEntity = canvasV2.layers.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'control_adapter') {
selectedEntity = canvasV2.controlAdapters.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'ip_adapter') {
selectedEntity = canvasV2.ipAdapters.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'regional_guidance') {
selectedEntity = canvasV2.regions.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'inpaint_mask') {
selectedEntity = canvasV2.inpaintMask;
} else {
selectedEntity = null;
}
return selectedEntity;
};
const selectCurrentFill = (canvasV2: CanvasV2State, selectedEntity: CanvasEntity | null) => {
let currentFill: RgbaColor = canvasV2.tool.fill;
if (selectedEntity) {
if (selectedEntity.type === 'regional_guidance') {
currentFill = { ...selectedEntity.fill, a: canvasV2.settings.maskOpacity };
} else if (selectedEntity.type === 'inpaint_mask') {
currentFill = { ...canvasV2.inpaintMask.fill, a: canvasV2.settings.maskOpacity };
}
} else {
currentFill = canvasV2.tool.fill;
}
return currentFill;
};
const { getState, subscribe, dispatch } = store;
// On the first render, we need to render everything.
let isFirstRender = true;
// Stage interaction listeners need helpers to get and update current state. Some of the state is read-only, like
// bbox, document and tool state, while interaction state is read-write.
// Read-only state, derived from redux
let prevCanvasV2 = getState().canvasV2;
let canvasV2 = getState().canvasV2;
const getSelectedEntity = () => selectSelectedEntity(canvasV2);
const getCurrentFill = () => selectCurrentFill(canvasV2, getSelectedEntity());
const getBbox = () => canvasV2.bbox;
const getDocument = () => canvasV2.document;
const getToolState = () => canvasV2.tool;
const getSettings = () => canvasV2.settings;
const getRegionsState = () => canvasV2.regions;
const getLayersState = () => canvasV2.layers;
const getControlAdaptersState = () => canvasV2.controlAdapters;
const getInpaintMaskState = () => canvasV2.inpaintMask;
const getMaskOpacity = () => canvasV2.settings.maskOpacity;
const getStagingAreaState = () => canvasV2.stagingArea;
const getIsSelected = (id: string) => getSelectedEntity()?.id === id;
// Read-only state, derived from nanostores
const resetLastProgressEvent = () => {
$lastProgressEvent.set(null);
};
// Read-write state, ephemeral interaction state
let isDrawing = false;
const getIsDrawing = () => isDrawing;
const setIsDrawing = (val: boolean) => {
isDrawing = val;
};
let isMouseDown = false;
const getIsMouseDown = () => isMouseDown;
const setIsMouseDown = (val: boolean) => {
isMouseDown = val;
};
let lastAddedPoint: Vector2d | null = null;
const getLastAddedPoint = () => lastAddedPoint;
const setLastAddedPoint = (val: Vector2d | null) => {
lastAddedPoint = val;
};
let lastMouseDownPos: Vector2d | null = null;
const getLastMouseDownPos = () => lastMouseDownPos;
const setLastMouseDownPos = (val: Vector2d | null) => {
lastMouseDownPos = val;
};
let lastCursorPos: Vector2d | null = null;
const getLastCursorPos = () => lastCursorPos;
const setLastCursorPos = (val: Vector2d | null) => {
lastCursorPos = val;
};
let spaceKey = false;
const getSpaceKey = () => spaceKey;
const setSpaceKey = (val: boolean) => {
spaceKey = val;
};
const stateApi: KonvaNodeManager['stateApi'] = {
// Read-only state
getToolState,
getSelectedEntity,
getBbox,
getSettings,
getCurrentFill,
getAltKey: $alt.get,
getCtrlKey: $ctrl.get,
getMetaKey: $meta.get,
getShiftKey: $shift.get,
getControlAdaptersState,
getDocument,
getLayersState,
getRegionsState,
getMaskOpacity,
getInpaintMaskState,
getStagingAreaState,
getShouldShowStagedImage: $shouldShowStagedImage.get,
getLastProgressEvent: $lastProgressEvent.get,
resetLastProgressEvent,
getIsSelected,
// Read-write state
setTool,
setToolBuffer,
getIsDrawing,
setIsDrawing,
getIsMouseDown,
setIsMouseDown,
getLastAddedPoint,
setLastAddedPoint,
getLastCursorPos,
setLastCursorPos,
getLastMouseDownPos,
setLastMouseDownPos,
getSpaceKey,
setSpaceKey,
setStageAttrs: $stageAttrs.set,
// Callbacks
onBrushLineAdded,
onEraserLineAdded,
onPointAddedToLine,
onRectShapeAdded,
onBrushWidthChanged,
onEraserWidthChanged,
onPosChanged,
onBboxTransformed,
onRegionMaskImageCached,
onInpaintMaskImageCached,
onLayerImageCached,
onScaleChanged,
};
const manager = new KonvaNodeManager(stage, container, stateApi);
setNodeManager(manager);
console.log(manager);
const cleanupListeners = setStageEventHandlers(manager);
// Calculating bounding boxes is expensive, must be debounced to not block the UI thread during a user interaction.
// TODO(psyche): Figure out how to do this in a worker. Probably means running the renderer in a worker and sending
// the entire state over when needed.
const debouncedUpdateBboxes = debounce(updateBboxes, 300);
const renderCanvas = async () => {
canvasV2 = store.getState().canvasV2;
if (prevCanvasV2 === canvasV2 && !isFirstRender) {
logIfDebugging('No changes detected, skipping render');
return;
}
if (
isFirstRender ||
canvasV2.layers.entities !== prevCanvasV2.layers.entities ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected ||
canvasV2.selectedEntityIdentifier?.id !== prevCanvasV2.selectedEntityIdentifier?.id
) {
logIfDebugging('Rendering layers');
manager.renderLayers();
}
if (
isFirstRender ||
canvasV2.regions.entities !== prevCanvasV2.regions.entities ||
canvasV2.settings.maskOpacity !== prevCanvasV2.settings.maskOpacity ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected ||
canvasV2.selectedEntityIdentifier?.id !== prevCanvasV2.selectedEntityIdentifier?.id
) {
logIfDebugging('Rendering regions');
manager.renderRegions();
}
if (
isFirstRender ||
canvasV2.inpaintMask !== prevCanvasV2.inpaintMask ||
canvasV2.settings.maskOpacity !== prevCanvasV2.settings.maskOpacity ||
canvasV2.tool.selected !== prevCanvasV2.tool.selected ||
canvasV2.selectedEntityIdentifier?.id !== prevCanvasV2.selectedEntityIdentifier?.id
) {
logIfDebugging('Rendering inpaint mask');
manager.renderInpaintMask();
}
if (
isFirstRender ||
canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities ||
canvasV2.selectedEntityIdentifier?.id !== prevCanvasV2.selectedEntityIdentifier?.id
) {
logIfDebugging('Rendering control adapters');
manager.renderControlAdapters();
}
if (isFirstRender || canvasV2.document !== prevCanvasV2.document) {
logIfDebugging('Rendering document bounds overlay');
manager.renderDocumentSizeOverlay();
}
if (isFirstRender || canvasV2.bbox !== prevCanvasV2.bbox || canvasV2.tool.selected !== prevCanvasV2.tool.selected) {
logIfDebugging('Rendering generation bbox');
manager.renderBbox();
}
if (
isFirstRender ||
canvasV2.layers !== prevCanvasV2.layers ||
canvasV2.controlAdapters !== prevCanvasV2.controlAdapters ||
canvasV2.regions !== prevCanvasV2.regions
) {
// logIfDebugging('Updating entity bboxes');
// debouncedUpdateBboxes(stage, canvasV2.layers, canvasV2.controlAdapters, canvasV2.regions, onBboxChanged);
}
if (isFirstRender || canvasV2.stagingArea !== prevCanvasV2.stagingArea) {
logIfDebugging('Rendering staging area');
manager.renderStagingArea();
}
if (
isFirstRender ||
canvasV2.layers.entities !== prevCanvasV2.layers.entities ||
canvasV2.controlAdapters.entities !== prevCanvasV2.controlAdapters.entities ||
canvasV2.regions.entities !== prevCanvasV2.regions.entities ||
canvasV2.inpaintMask !== prevCanvasV2.inpaintMask ||
canvasV2.selectedEntityIdentifier?.id !== prevCanvasV2.selectedEntityIdentifier?.id
) {
logIfDebugging('Arranging entities');
manager.arrangeEntities();
}
prevCanvasV2 = canvasV2;
if (isFirstRender) {
isFirstRender = false;
}
};
// We can use a resize observer to ensure the stage always fits the container. We also need to re-render the bg and
// document bounds overlay when the stage is resized.
const resizeObserver = new ResizeObserver(manager.fitStageToContainer.bind(manager));
resizeObserver.observe(container);
manager.fitStageToContainer();
const unsubscribeRenderer = subscribe(renderCanvas);
// When we this flag, we need to render the staging area
$shouldShowStagedImage.subscribe((shouldShowStagedImage, prevShouldShowStagedImage) => {
logIfDebugging('Rendering staging area');
if (shouldShowStagedImage !== prevShouldShowStagedImage) {
manager.renderStagingArea();
}
});
$lastProgressEvent.subscribe(() => {
logIfDebugging('Rendering staging area');
manager.renderStagingArea();
});
logIfDebugging('First render of konva stage');
// On first render, the document should be fit to the stage.
manager.renderDocumentSizeOverlay();
manager.fitDocument();
manager.renderToolPreview();
renderCanvas();
return () => {
logIfDebugging('Cleaning up konva renderer');
unsubscribeRenderer();
cleanupListeners();
$shouldShowStagedImage.off();
resizeObserver.disconnect();
};
};

View File

@ -1,29 +1,29 @@
import { KonvaImage, KonvaProgressImage } from 'features/controlLayers/konva/renderers/objects';
import type { CanvasV2State } from 'features/controlLayers/store/types';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import { KonvaImage, KonvaProgressImage } from 'features/controlLayers/konva/objects';
import Konva from 'konva';
import type { ImageDTO } from 'services/api/types';
import type { InvocationDenoiseProgressEvent } from 'services/events/types';
export class CanvasStagingArea {
group: Konva.Group;
image: KonvaImage | null;
progressImage: KonvaProgressImage | null;
imageDTO: ImageDTO | null;
manager: KonvaNodeManager;
constructor() {
constructor(manager: KonvaNodeManager) {
this.manager = manager;
this.group = new Konva.Group({ listening: false });
this.image = null;
this.progressImage = null;
this.imageDTO = null;
}
async render(
stagingArea: CanvasV2State['stagingArea'],
bbox: CanvasV2State['bbox'],
shouldShowStagedImage: boolean,
lastProgressEvent: InvocationDenoiseProgressEvent | null,
resetLastProgressEvent: () => void
) {
async render() {
const stagingArea = this.manager.stateApi.getStagingAreaState();
const bbox = this.manager.stateApi.getBbox();
const shouldShowStagedImage = this.manager.stateApi.getShouldShowStagedImage();
const lastProgressEvent = this.manager.stateApi.getLastProgressEvent();
this.imageDTO = stagingArea.images[stagingArea.selectedImageIndex] ?? null;
if (this.imageDTO) {
@ -58,7 +58,7 @@ export class CanvasStagingArea {
konvaImage.width(this.imageDTO.width);
konvaImage.height(this.imageDTO.height);
}
resetLastProgressEvent();
this.manager.stateApi.resetLastProgressEvent();
},
}
);
@ -100,7 +100,7 @@ export class CanvasStagingArea {
if (this.progressImage) {
this.progressImage.konvaImageGroup.visible(false);
}
resetLastProgressEvent();
this.manager.stateApi.resetLastProgressEvent();
}
}
}

View File

@ -0,0 +1,237 @@
import { $alt, $ctrl, $meta, $shift } from "@invoke-ai/ui-library";
import type { Store } from "@reduxjs/toolkit";
import type { RootState } from "app/store/store";
import { $isDrawing, $isMouseDown, $lastAddedPoint, $lastCursorPos, $lastMouseDownPos, $lastProgressEvent, $shouldShowStagedImage, $spaceKey, $stageAttrs, bboxChanged, brushWidthChanged, caBboxChanged, caTranslated, eraserWidthChanged, imBboxChanged, imBrushLineAdded, imEraserLineAdded, imImageCacheChanged, imLinePointAdded, imRectAdded, imScaled, imTranslated, layerBboxChanged, layerBrushLineAdded, layerEraserLineAdded, layerImageCacheChanged, layerLinePointAdded, layerRectAdded, layerScaled, layerTranslated, rgBboxChanged, rgBrushLineAdded, rgEraserLineAdded, rgImageCacheChanged, rgLinePointAdded, rgRectAdded, rgScaled, rgTranslated, toolBufferChanged, toolChanged } from "features/controlLayers/store/canvasV2Slice";
import type { BboxChangedArg, BrushLineAddedArg, CanvasEntity, EraserLineAddedArg, PointAddedToLineArg, PosChangedArg, RectShapeAddedArg, ScaleChangedArg, Tool } from "features/controlLayers/store/types";
import type { IRect } from "konva/lib/types";
import type { RgbaColor } from "react-colorful";
import type { ImageDTO } from "services/api/types";
export class StateApi {
private store: Store<RootState>;
private log: (message: string) => void;
constructor(store: Store<RootState>, log: (message: string) => void) {
this.store = store;
this.log = log;
}
// Reminder - use arrow functions to avoid binding issues
getState = () => {
return this.store.getState().canvasV2;
};
onPosChanged = (arg: PosChangedArg, entityType: CanvasEntity['type']) => {
this.log('onPosChanged');
if (entityType === 'layer') {
this.store.dispatch(layerTranslated(arg));
} else if (entityType === 'control_adapter') {
this.store.dispatch(caTranslated(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgTranslated(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imTranslated(arg));
}
};
onScaleChanged = (arg: ScaleChangedArg, entityType: CanvasEntity['type']) => {
this.log('onScaleChanged');
if (entityType === 'layer') {
this.store.dispatch(layerScaled(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imScaled(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgScaled(arg));
}
};
onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => {
this.log('Entity bbox changed');
if (entityType === 'layer') {
this.store.dispatch(layerBboxChanged(arg));
} else if (entityType === 'control_adapter') {
this.store.dispatch(caBboxChanged(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgBboxChanged(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imBboxChanged(arg));
}
};
onBrushLineAdded = (arg: BrushLineAddedArg, entityType: CanvasEntity['type']) => {
this.log('Brush line added');
if (entityType === 'layer') {
this.store.dispatch(layerBrushLineAdded(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgBrushLineAdded(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imBrushLineAdded(arg));
}
};
onEraserLineAdded = (arg: EraserLineAddedArg, entityType: CanvasEntity['type']) => {
this.log('Eraser line added');
if (entityType === 'layer') {
this.store.dispatch(layerEraserLineAdded(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgEraserLineAdded(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imEraserLineAdded(arg));
}
};
onPointAddedToLine = (arg: PointAddedToLineArg, entityType: CanvasEntity['type']) => {
this.log('Point added to line');
if (entityType === 'layer') {
this.store.dispatch(layerLinePointAdded(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgLinePointAdded(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imLinePointAdded(arg));
}
};
onRectShapeAdded = (arg: RectShapeAddedArg, entityType: CanvasEntity['type']) => {
this.log('Rect shape added');
if (entityType === 'layer') {
this.store.dispatch(layerRectAdded(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgRectAdded(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imRectAdded(arg));
}
};
onBboxTransformed = (bbox: IRect) => {
this.log('Generation bbox transformed');
this.store.dispatch(bboxChanged(bbox));
};
onBrushWidthChanged = (width: number) => {
this.log('Brush width changed');
this.store.dispatch(brushWidthChanged(width));
};
onEraserWidthChanged = (width: number) => {
this.log('Eraser width changed');
this.store.dispatch(eraserWidthChanged(width));
};
onRegionMaskImageCached = (id: string, imageDTO: ImageDTO) => {
this.log('Region mask image cached');
this.store.dispatch(rgImageCacheChanged({ id, imageDTO }));
};
onInpaintMaskImageCached = (imageDTO: ImageDTO) => {
this.log('Inpaint mask image cached');
this.store.dispatch(imImageCacheChanged({ imageDTO }));
};
onLayerImageCached = (imageDTO: ImageDTO) => {
this.log('Layer image cached');
this.store.dispatch(layerImageCacheChanged({ imageDTO }));
};
setTool = (tool: Tool) => {
this.log('Tool selection changed');
this.store.dispatch(toolChanged(tool));
};
setToolBuffer = (toolBuffer: Tool | null) => {
this.log('Tool buffer changed');
this.store.dispatch(toolBufferChanged(toolBuffer));
};
getSelectedEntity = (): CanvasEntity | null => {
const state = this.getState();
const identifier = state.selectedEntityIdentifier;
let selectedEntity: CanvasEntity | null = null;
if (!identifier) {
selectedEntity = null;
} else if (identifier.type === 'layer') {
selectedEntity = state.layers.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'control_adapter') {
selectedEntity = state.controlAdapters.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'ip_adapter') {
selectedEntity = state.ipAdapters.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'regional_guidance') {
selectedEntity = state.regions.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'inpaint_mask') {
selectedEntity = state.inpaintMask;
} else {
selectedEntity = null;
}
return selectedEntity;
};
getCurrentFill = () => {
const state = this.getState();
const selectedEntity = this.getSelectedEntity();
let currentFill: RgbaColor = state.tool.fill;
if (selectedEntity) {
if (selectedEntity.type === 'regional_guidance') {
currentFill = { ...selectedEntity.fill, a: state.settings.maskOpacity };
} else if (selectedEntity.type === 'inpaint_mask') {
currentFill = { ...state.inpaintMask.fill, a: state.settings.maskOpacity };
}
} else {
currentFill = state.tool.fill;
}
return currentFill;
};
getBbox = () => {
return this.getState().bbox;
};
getDocument = () => {
return this.getState().document;
};
getToolState = () => {
return this.getState().tool;
};
getSettings = () => {
return this.getState().settings;
};
getRegionsState = () => {
return this.getState().regions;
};
getLayersState = () => {
return this.getState().layers;
};
getControlAdaptersState = () => {
return this.getState().controlAdapters;
};
getInpaintMaskState = () => {
return this.getState().inpaintMask;
};
getMaskOpacity = () => {
return this.getState().settings.maskOpacity;
};
getStagingAreaState = () => {
return this.getState().stagingArea;
};
getIsSelected = (id: string) => {
return this.getSelectedEntity()?.id === id;
};
// Read-only state, derived from nanostores
resetLastProgressEvent = () => {
$lastProgressEvent.set(null);
};
// Read-write state, ephemeral interaction state
getIsDrawing = $isDrawing.get;
setIsDrawing = $isDrawing.set;
getIsMouseDown = $isMouseDown.get;
setIsMouseDown = $isMouseDown.set;
getLastAddedPoint = $lastAddedPoint.get;
setLastAddedPoint = $lastAddedPoint.set;
getLastMouseDownPos = $lastMouseDownPos.get;
setLastMouseDownPos = $lastMouseDownPos.set;
getLastCursorPos = $lastCursorPos.get;
setLastCursorPos = $lastCursorPos.set;
getSpaceKey = $spaceKey.get;
setSpaceKey = $spaceKey.set;
getLastProgressEvent = $lastProgressEvent.get;
setLastProgressEvent = $lastProgressEvent.set;
getAltKey = $alt.get;
getCtrlKey = $ctrl.get;
getMetaKey = $meta.get;
getShiftKey = $shift.get;
getShouldShowStagedImage = $shouldShowStagedImage.get;
setStageAttrs = $stageAttrs.set;
}

View File

@ -5,10 +5,11 @@ import {
BRUSH_ERASER_BORDER_WIDTH,
} from 'features/controlLayers/konva/constants';
import { PREVIEW_RECT_ID } from 'features/controlLayers/konva/naming';
import type { CanvasEntity, CanvasV2State, Position, RgbaColor } from 'features/controlLayers/store/types';
import type { KonvaNodeManager } from 'features/controlLayers/konva/nodeManager';
import Konva from 'konva';
export class CanvasTool {
manager: KonvaNodeManager;
group: Konva.Group;
brush: {
group: Konva.Group;
@ -27,7 +28,8 @@ export class CanvasTool {
fillRect: Konva.Rect;
};
constructor() {
constructor(manager: KonvaNodeManager) {
this.manager = manager;
this.group = new Konva.Group();
// Create the brush preview group & circles
@ -94,8 +96,9 @@ export class CanvasTool {
this.group.add(this.rect.group);
}
scaleTool(stage: Konva.Stage, toolState: CanvasV2State['tool']) {
const scale = stage.scaleX();
scaleTool = () => {
const toolState = this.manager.stateApi.getToolState();
const scale = this.manager.stage.scaleX();
const brushRadius = toolState.brush.width / 2;
this.brush.innerBorderCircle.strokeWidth(BRUSH_ERASER_BORDER_WIDTH / scale);
@ -110,19 +113,19 @@ export class CanvasTool {
strokeWidth: BRUSH_ERASER_BORDER_WIDTH / scale,
radius: eraserRadius + BRUSH_ERASER_BORDER_WIDTH / scale,
});
}
};
render() {
const stage = this.manager.stage;
const renderedEntityCount: number = 1; // TODO(psyche): this.manager should be renderable entity count
const toolState = this.manager.stateApi.getToolState();
const currentFill = this.manager.stateApi.getCurrentFill();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const cursorPos = this.manager.stateApi.getLastCursorPos();
const lastMouseDownPos = this.manager.stateApi.getLastMouseDownPos();
const isDrawing = this.manager.stateApi.getIsDrawing();
const isMouseDown = this.manager.stateApi.getIsMouseDown();
render(
stage: Konva.Stage,
renderedEntityCount: number,
toolState: CanvasV2State['tool'],
currentFill: RgbaColor,
selectedEntity: CanvasEntity | null,
cursorPos: Position | null,
lastMouseDownPos: Position | null,
isDrawing: boolean,
isMouseDown: boolean
) {
const tool = toolState.selected;
const isDrawableEntity =
selectedEntity?.type === 'regional_guidance' ||
@ -182,7 +185,7 @@ export class CanvasTool {
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
});
this.scaleTool(stage, toolState);
this.scaleTool();
this.brush.group.visible(true);
this.eraser.group.visible(false);
@ -208,7 +211,7 @@ export class CanvasTool {
radius: radius + BRUSH_ERASER_BORDER_WIDTH / scale,
});
this.scaleTool(stage, toolState);
this.scaleTool();
this.brush.group.visible(false);
this.eraser.group.visible(true);

View File

@ -1,6 +1,6 @@
import {
CA_LAYER_NAME,
INPAINT_MASK_LAYER_NAME,
INPAINT_MASK_LAYER_ID,
RASTER_LAYER_BRUSH_LINE_NAME,
RASTER_LAYER_ERASER_LINE_NAME,
RASTER_LAYER_IMAGE_NAME,
@ -102,7 +102,7 @@ export const selectRenderableLayers = (node: Konva.Node): boolean =>
node.name() === RG_LAYER_NAME ||
node.name() === CA_LAYER_NAME ||
node.name() === RASTER_LAYER_NAME ||
node.name() === INPAINT_MASK_LAYER_NAME;
node.name() === INPAINT_MASK_LAYER_ID;
/**
* Konva selection callback to select RG mask objects. This includes lines and rects.

View File

@ -3,6 +3,7 @@ import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
import { roundDownToMultiple } from 'common/util/roundDownToMultiple';
import { INPAINT_MASK_LAYER_ID } from 'features/controlLayers/konva/naming';
import { bboxReducers } from 'features/controlLayers/store/bboxReducers';
import { compositingReducers } from 'features/controlLayers/store/compositingReducers';
import { controlAdaptersReducers } from 'features/controlLayers/store/controlAdaptersReducers';
@ -20,19 +21,19 @@ import type { AspectRatioState } from 'features/parameters/components/ImageSize/
import { atom } from 'nanostores';
import type { InvocationDenoiseProgressEvent } from 'services/events/types';
import type { CanvasEntityIdentifier, CanvasV2State, StageAttrs } from './types';
import type { CanvasEntityIdentifier, CanvasV2State, Position, StageAttrs } from './types';
import { RGBA_RED } from './types';
const initialState: CanvasV2State = {
_version: 3,
selectedEntityIdentifier: { type: 'inpaint_mask', id: 'inpaint_mask' },
selectedEntityIdentifier: { type: 'inpaint_mask', id: INPAINT_MASK_LAYER_ID },
layers: { entities: [], imageCache: null },
controlAdapters: { entities: [] },
ipAdapters: { entities: [] },
regions: { entities: [] },
loras: [],
inpaintMask: {
id: 'inpaint_mask',
id: INPAINT_MASK_LAYER_ID,
type: 'inpaint_mask',
bbox: null,
bboxNeedsUpdate: false,
@ -366,6 +367,12 @@ export const $stageAttrs = atom<StageAttrs>({
});
export const $shouldShowStagedImage = atom(true);
export const $lastProgressEvent = atom<InvocationDenoiseProgressEvent | null>(null);
export const $isDrawing = atom<boolean>(false);
export const $isMouseDown = atom<boolean>(false);
export const $lastAddedPoint = atom<Position | null>(null);
export const $lastMouseDownPos = atom<Position | null>(null);
export const $lastCursorPos = atom<Position | null>(null);
export const $spaceKey = atom<boolean>(false);
export const canvasV2PersistConfig: PersistConfig<CanvasV2State> = {
name: canvasV2Slice.name,