diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts index 6d53d3a84d..f802319a06 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -1,6 +1,6 @@ import { enqueueRequested } from 'app/store/actions'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { getCanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { $canvasManager } from 'features/controlLayers/konva/CanvasManager'; import { sessionStagingAreaReset, sessionStartedStaging } from 'features/controlLayers/store/canvasV2Slice'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildSD1Graph } from 'features/nodes/util/graph/generation/buildSD1Graph'; @@ -17,6 +17,9 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) const model = state.canvasV2.params.model; const { prepend } = action.payload; + const manager = $canvasManager.get(); + assert(manager, 'No model found in state'); + let didStartStaging = false; if (!state.canvasV2.session.isStaging && state.canvasV2.session.isActive) { dispatch(sessionStartedStaging()); @@ -26,7 +29,6 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) try { let g; - const manager = getCanvasManager(); assert(model, 'No model found in state'); const base = model.base; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx index 7386461c27..7a3da1aacb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx @@ -1,6 +1,7 @@ /* eslint-disable i18next/no-literal-string */ import { Button } from '@chakra-ui/react'; import { Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { BrushWidth } from 'features/controlLayers/components/BrushWidth'; import ControlLayersSettingsPopover from 'features/controlLayers/components/ControlLayersSettingsPopover'; @@ -10,19 +11,22 @@ import { NewSessionButton } from 'features/controlLayers/components/NewSessionBu import { ResetCanvasButton } from 'features/controlLayers/components/ResetCanvasButton'; import { ToolChooser } from 'features/controlLayers/components/ToolChooser'; import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup'; -import { getCanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { $canvasManager } from 'features/controlLayers/konva/CanvasManager'; import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton'; import { ViewerToggleMenu } from 'features/gallery/components/ImageViewer/ViewerToggleMenu'; import { memo, useCallback } from 'react'; export const ControlLayersToolbar = memo(() => { const tool = useAppSelector((s) => s.canvasV2.tool.selected); + const canvasManager = useStore($canvasManager); const bbox = useCallback(() => { - const manager = getCanvasManager(); - for (const l of manager.layers.values()) { + if (!canvasManager) { + return; + } + for (const l of canvasManager.layers.values()) { l.getBbox(); } - }, []); + }, [canvasManager]); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx index 31072b0fa1..d6d63e1d0c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx @@ -2,7 +2,7 @@ import { Flex } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; import { useAppStore } from 'app/store/storeHooks'; import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay'; -import { CanvasManager, setCanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { $canvasManager, CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import Konva from 'konva'; import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { useDevicePixelRatio } from 'use-device-pixel-ratio'; @@ -28,7 +28,7 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null, } const manager = new CanvasManager(stage, container, store); - setCanvasManager(manager); + $canvasManager.set(manager); console.log(manager); const cleanup = manager.initialize(); return cleanup; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/TransformToolButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/TransformToolButton.tsx index 607e5acc3d..cf70f59ee9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/TransformToolButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/TransformToolButton.tsx @@ -1,38 +1,58 @@ import { Button, IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { toolIsTransformingChanged } from 'features/controlLayers/store/canvasV2Slice'; -import { memo, useCallback } from 'react'; +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { $canvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { memo, useCallback, useEffect, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiResizeBold } from 'react-icons/pi'; export const TransformToolButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const isTransforming = useAppSelector((s) => s.canvasV2.tool.isTransforming); + const canvasManager = useStore($canvasManager); + const [isTransforming, setIsTransforming] = useState(false); const isDisabled = useAppSelector( (s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging ); + useEffect(() => { + if (!canvasManager) { + return; + } + canvasManager.onTransform = setIsTransforming; + return () => { + canvasManager.onTransform = null; + }; + }, [canvasManager]); + const onTransform = useCallback(() => { - dispatch(toolIsTransformingChanged(true)); - }, [dispatch]); + if (!canvasManager) { + return; + } + canvasManager.startTransform(); + }, [canvasManager]); const onApplyTransformation = useCallback(() => { - false && dispatch(toolIsTransformingChanged(true)); - }, [dispatch]); + if (!canvasManager) { + return; + } + canvasManager.applyTransform(); + }, [canvasManager]); const onCancelTransformation = useCallback(() => { - dispatch(toolIsTransformingChanged(false)); - }, [dispatch]); + if (!canvasManager) { + return; + } + canvasManager.cancelTransform(); + }, [canvasManager]); useHotkeys(['ctrl+t', 'meta+t'], onTransform, { enabled: !isDisabled }, [isDisabled, onTransform]); if (isTransforming) { return ( <> - - + + ); } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts index 84c824022f..3de306581f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts @@ -44,8 +44,8 @@ export class CanvasBrushLine { this.state = state; } - update(state: BrushLine, force?: boolean): boolean { - if (this.state !== state || force) { + async update(state: BrushLine, force?: boolean): Promise { + if (force || this.state !== state) { const { points, color, clip, strokeWidth } = state; this.konva.line.setAttrs({ // A line with only one point will not be rendered, so we duplicate the points to make it visible diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts index f1d93fe9a9..32e7ccbd24 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts @@ -44,8 +44,8 @@ export class CanvasEraserLine { this.state = state; } - update(state: EraserLine, force?: boolean): boolean { - if (this.state !== state || force) { + async update(state: EraserLine, force?: boolean): Promise { + if (force || this.state !== state) { const { points, clip, strokeWidth } = state; this.konva.line.setAttrs({ // A line with only one point will not be rendered, so we duplicate the points to make it visible diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts index 66fa5ca47d..cea1624f27 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts @@ -1,14 +1,23 @@ -import { deepClone } from 'common/util/deepClone'; +import { getStore } from 'app/store/nanostores/store'; import { CanvasBrushLine } from 'features/controlLayers/konva/CanvasBrushLine'; import { CanvasEraserLine } from 'features/controlLayers/konva/CanvasEraserLine'; import { CanvasImage } from 'features/controlLayers/konva/CanvasImage'; -import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasRect } from 'features/controlLayers/konva/CanvasRect'; -import { mapId } from 'features/controlLayers/konva/util'; -import type { BrushLine, EraserLine, LayerEntity, RectShape } from 'features/controlLayers/store/types'; -import { isDrawingTool } from 'features/controlLayers/store/types'; +import { konvaNodeToBlob, mapId, previewBlob } from 'features/controlLayers/konva/util'; +import { layerRasterized } from 'features/controlLayers/store/canvasV2Slice'; +import type { + BrushLine, + CanvasV2State, + Coordinate, + EraserLine, + LayerEntity, + RectShape, +} from 'features/controlLayers/store/types'; import Konva from 'konva'; -import { debounce } from 'lodash-es'; +import { debounce, get } from 'lodash-es'; +import type { Logger } from 'roarr'; +import { uploadImage } from 'services/api/endpoints/images'; import { assert } from 'tsafe'; export class CanvasLayer { @@ -20,8 +29,6 @@ export class CanvasLayer { static OBJECT_GROUP_NAME = `${CanvasLayer.NAME_PREFIX}_object-group`; static BBOX_NAME = `${CanvasLayer.NAME_PREFIX}_bbox`; - private static BBOX_PADDING_PX = 5; - private drawingBuffer: BrushLine | EraserLine | RectShape | null; private state: LayerEntity; @@ -41,8 +48,10 @@ export class CanvasLayer { offsetY: number; width: number; height: number; - - getBbox = debounce(this._getBbox, 300); + log: Logger; + bboxNeedsUpdate: boolean; + isTransforming: boolean; + isFirstRender: boolean; constructor(state: LayerEntity, manager: CanvasManager) { this.id = state.id; @@ -60,12 +69,12 @@ export class CanvasLayer { objectGroup: new Konva.Group({ name: CanvasLayer.OBJECT_GROUP_NAME, listening: false }), transformer: new Konva.Transformer({ name: CanvasLayer.TRANSFORMER_NAME, - draggable: true, + draggable: false, // enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'], rotateEnabled: true, flipEnabled: true, listening: false, - padding: CanvasLayer.BBOX_PADDING_PX, + padding: this.manager.getScaledBboxPadding(), stroke: 'hsl(200deg 76% 59%)', // invokeBlue.400 keepRatio: false, }), @@ -135,19 +144,30 @@ export class CanvasLayer { }); this.konva.objectGroup.setAttrs({ - x: this.konva.interactionRect.x(), - y: this.konva.interactionRect.y(), + x: this.konva.interactionRect.x() - this.offsetX * this.konva.interactionRect.scaleX(), + y: this.konva.interactionRect.y() - this.offsetY * this.konva.interactionRect.scaleY(), scaleX: this.konva.interactionRect.scaleX(), scaleY: this.konva.interactionRect.scaleY(), rotation: this.konva.interactionRect.rotation(), }); + console.log('objectGroup', { + x: this.konva.objectGroup.x(), + y: this.konva.objectGroup.y(), + scaleX: this.konva.objectGroup.scaleX(), + scaleY: this.konva.objectGroup.scaleY(), + offsetX: this.offsetX, + offsetY: this.offsetY, + width: this.konva.objectGroup.width(), + height: this.konva.objectGroup.height(), + rotation: this.konva.objectGroup.rotation(), + }); }); this.konva.transformer.on('transformend', () => { - this.offsetX = this.konva.interactionRect.x() - this.state.position.x; - this.offsetY = this.konva.interactionRect.y() - this.state.position.y; - this.width = Math.round(this.konva.interactionRect.width() * this.konva.interactionRect.scaleX()); - this.height = Math.round(this.konva.interactionRect.height() * this.konva.interactionRect.scaleY()); + // this.offsetX = this.konva.interactionRect.x() - this.state.position.x; + // this.offsetY = this.konva.interactionRect.y() - this.state.position.y; + // this.width = Math.round(this.konva.interactionRect.width() * this.konva.interactionRect.scaleX()); + // this.height = Math.round(this.konva.interactionRect.height() * this.konva.interactionRect.scaleY()); // this.manager.stateApi.onPosChanged( // { // id: this.id, @@ -166,26 +186,33 @@ export class CanvasLayer { // The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding // and border this.konva.bbox.setAttrs({ - x: this.konva.interactionRect.x() - CanvasLayer.BBOX_PADDING_PX / this.manager.stage.scaleX(), - y: this.konva.interactionRect.y() - CanvasLayer.BBOX_PADDING_PX / this.manager.stage.scaleX(), + x: this.konva.interactionRect.x() - this.manager.getScaledBboxPadding(), + y: this.konva.interactionRect.y() - this.manager.getScaledBboxPadding(), }); // The object group is translated by the difference between the interaction rect's new and old positions (which is // stored as this.bbox) this.konva.objectGroup.setAttrs({ - x: this.konva.interactionRect.x(), - y: this.konva.interactionRect.y(), + x: this.konva.interactionRect.x() - this.offsetX * this.konva.interactionRect.scaleX(), + y: this.konva.interactionRect.y() - this.offsetY * this.konva.interactionRect.scaleY(), }); }); this.konva.interactionRect.on('dragend', () => { this.logBbox('dragend bbox'); - // Update internal state - // this.state.position = { x: this.konva.objectGroup.x(), y: this.konva.objectGroup.y() }; + if (this.isTransforming) { + // When the user cancels the transformation, we need to reset the layer, so we should not update the layer's + // positition while we are transforming - bail out early. + return; + } + this.manager.stateApi.onPosChanged( { id: this.id, - position: { x: this.konva.objectGroup.x(), y: this.konva.objectGroup.y() }, + position: { + x: this.konva.interactionRect.x() - this.offsetX * this.konva.interactionRect.scaleX(), + y: this.konva.interactionRect.y() - this.offsetY * this.konva.interactionRect.scaleY(), + }, }, 'layer' ); @@ -198,15 +225,16 @@ export class CanvasLayer { this.offsetY = 0; this.width = 0; this.height = 0; + this.bboxNeedsUpdate = true; + this.isTransforming = false; + this.isFirstRender = true; + this.log = this.manager.getLogger(`layer_${this.id}`); console.log(this); } - private static get DEFAULT_BBOX_RECT() { - return { x: 0, y: 0, width: 0, height: 0 }; - } - destroy(): void { + this.log.debug(`Layer ${this.id} - destroying`); this.konva.layer.destroy(); } @@ -214,99 +242,222 @@ export class CanvasLayer { return this.drawingBuffer; } - updatePosition() { - const scale = this.manager.stage.scaleX(); - const onePixel = 1 / scale; - const bboxPadding = CanvasLayer.BBOX_PADDING_PX / scale; - - this.konva.objectGroup.setAttrs({ - x: this.state.position.x, - y: this.state.position.y, - offsetX: this.offsetX, - offsetY: this.offsetY, - }); - this.konva.bbox.setAttrs({ - x: this.state.position.x - bboxPadding, - y: this.state.position.y - bboxPadding, - width: this.width + bboxPadding * 2, - height: this.height + bboxPadding * 2, - strokeWidth: onePixel, - }); - this.konva.interactionRect.setAttrs({ - x: this.state.position.x, - y: this.state.position.y, - width: this.width, - height: this.height, - }); - } - async setDrawingBuffer(obj: BrushLine | EraserLine | RectShape | null) { if (obj) { this.drawingBuffer = obj; - await this.renderObject(this.drawingBuffer, true); - this.updateGroup(true); + await this._renderObject(this.drawingBuffer, true); } else { this.drawingBuffer = null; } } - finalizeDrawingBuffer() { + async finalizeDrawingBuffer() { if (!this.drawingBuffer) { return; } - if (this.drawingBuffer.type === 'brush_line') { - this.manager.stateApi.onBrushLineAdded({ id: this.id, brushLine: this.drawingBuffer }, 'layer'); - } else if (this.drawingBuffer.type === 'eraser_line') { - this.manager.stateApi.onEraserLineAdded({ id: this.id, eraserLine: this.drawingBuffer }, 'layer'); - } else if (this.drawingBuffer.type === 'rect_shape') { - this.manager.stateApi.onRectShapeAdded({ id: this.id, rectShape: this.drawingBuffer }, 'layer'); - } + const drawingBuffer = this.drawingBuffer; this.setDrawingBuffer(null); + + if (drawingBuffer.type === 'brush_line') { + this.manager.stateApi.onBrushLineAdded({ id: this.id, brushLine: drawingBuffer }, 'layer'); + } else if (drawingBuffer.type === 'eraser_line') { + this.manager.stateApi.onEraserLineAdded({ id: this.id, eraserLine: drawingBuffer }, 'layer'); + } else if (drawingBuffer.type === 'rect_shape') { + this.manager.stateApi.onRectShapeAdded({ id: this.id, rectShape: drawingBuffer }, 'layer'); + } } - async render(state: LayerEntity) { - this.state = deepClone(state); + async update(arg?: { state: LayerEntity; toolState: CanvasV2State['tool']; isSelected: boolean }) { + const state = get(arg, 'state', this.state); + const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState()); + const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id)); - let didDraw = false; + if (!this.isFirstRender && state === this.state) { + this.log.trace('State unchanged, skipping update'); + return; + } - const objectIds = state.objects.map(mapId); + this.log.debug('Updating'); + const { position, objects, opacity, isEnabled } = state; + + if (this.isFirstRender || position !== this.state.position) { + await this.updatePosition({ position }); + } + if (this.isFirstRender || objects !== this.state.objects) { + await this.updateObjects({ objects }); + } + if (this.isFirstRender || opacity !== this.state.opacity) { + await this.updateOpacity({ opacity }); + } + if (this.isFirstRender || isEnabled !== this.state.isEnabled) { + await this.updateVisibility({ isEnabled }); + } + await this.updateInteraction({ toolState, isSelected }); + this.state = state; + } + + async updateVisibility(arg?: { isEnabled: boolean }) { + this.log.trace('Updating visibility'); + const isEnabled = get(arg, 'isEnabled', this.state.isEnabled); + const hasObjects = this.objects.size > 0 || this.drawingBuffer !== null; + this.konva.layer.visible(isEnabled || hasObjects); + } + + async updatePosition(arg?: { position: Coordinate }) { + this.log.trace('Updating position'); + const position = get(arg, 'position', this.state.position); + const bboxPadding = this.manager.getScaledBboxPadding(); + + this.konva.objectGroup.setAttrs({ + x: position.x, + y: position.y, + }); + this.konva.bbox.setAttrs({ + x: position.x + this.offsetX * this.konva.interactionRect.scaleX() - bboxPadding, + y: position.y + this.offsetY * this.konva.interactionRect.scaleY() - bboxPadding, + }); + this.konva.interactionRect.setAttrs({ + x: position.x + this.offsetX * this.konva.interactionRect.scaleX(), + y: position.y + this.offsetY * this.konva.interactionRect.scaleY(), + }); + } + + async updateObjects(arg?: { objects: LayerEntity['objects'] }) { + this.log.trace('Updating objects'); + + const objects = get(arg, 'objects', this.state.objects); + + const objectIds = objects.map(mapId); // Destroy any objects that are no longer in state for (const object of this.objects.values()) { if (!objectIds.includes(object.id) && object.id !== this.drawingBuffer?.id) { this.objects.delete(object.id); object.destroy(); - didDraw = true; + this.bboxNeedsUpdate = true; } } - for (const obj of state.objects) { - if (await this.renderObject(obj)) { - didDraw = true; + for (const obj of objects) { + if (await this._renderObject(obj)) { + this.bboxNeedsUpdate = true; } } if (this.drawingBuffer) { - if (await this.renderObject(this.drawingBuffer)) { - didDraw = true; + if (await this._renderObject(this.drawingBuffer)) { + this.bboxNeedsUpdate = true; } } - - this.renderBbox(); - this.updateGroup(didDraw); } - private async renderObject(obj: LayerEntity['objects'][number], force = false): Promise { + async updateOpacity(arg?: { opacity: number }) { + this.log.trace('Updating opacity'); + + const opacity = get(arg, 'opacity', this.state.opacity); + + this.konva.objectGroup.opacity(opacity); + } + + async updateInteraction(arg?: { toolState: CanvasV2State['tool']; isSelected: boolean }) { + this.log.trace('Updating interaction'); + + const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState()); + const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id)); + + if (this.objects.size === 0) { + // The layer is totally empty, we can just disable the layer + this.konva.layer.listening(false); + return; + } + + if (isSelected && !this.isTransforming && toolState.selected === 'move') { + // We are moving this layer, it must be listening + this.konva.layer.listening(true); + + // The transformer is not needed + this.konva.transformer.listening(false); + this.konva.transformer.nodes([]); + + // The bbox rect should be visible and interaction rect listening for dragging + this.konva.bbox.visible(true); + this.konva.interactionRect.listening(true); + } else if (isSelected && this.isTransforming) { + // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or + // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening + // when the view tool is selected + const listening = toolState.selected !== 'view'; + this.konva.layer.listening(listening); + this.konva.interactionRect.listening(listening); + this.konva.transformer.listening(listening); + + // The transformer transforms the interaction rect, not the object group + this.konva.transformer.nodes([this.konva.interactionRect]); + + // Hide the bbox rect, the transformer will has its own bbox + this.konva.bbox.visible(false); + } else { + // The layer is not selected, or we are using a tool that doesn't need the layer to be listening - disable interaction stuff + this.konva.layer.listening(false); + + // The transformer, bbox and interaction rect should be inactive + this.konva.transformer.listening(false); + this.konva.transformer.nodes([]); + this.konva.bbox.visible(false); + this.konva.interactionRect.listening(false); + } + } + + async updateBbox() { + this.log.trace('Updating bbox'); + + const onePixel = this.manager.getScaledPixel(); + const bboxPadding = this.manager.getScaledBboxPadding(); + + this.konva.bbox.setAttrs({ + x: this.state.position.x + this.offsetX * this.konva.interactionRect.scaleX() - bboxPadding, + y: this.state.position.y + this.offsetY * this.konva.interactionRect.scaleY() - bboxPadding, + width: this.width + bboxPadding * 2, + height: this.height + bboxPadding * 2, + strokeWidth: onePixel, + }); + this.konva.interactionRect.setAttrs({ + x: this.state.position.x + this.offsetX * this.konva.interactionRect.scaleX(), + y: this.state.position.y + this.offsetY * this.konva.interactionRect.scaleY(), + width: this.width, + height: this.height, + }); + } + + async syncStageScale() { + this.log.trace('Syncing scale to stage'); + + const onePixel = this.manager.getScaledPixel(); + const bboxPadding = this.manager.getScaledBboxPadding(); + + this.konva.bbox.setAttrs({ + x: this.konva.interactionRect.x() - bboxPadding, + y: this.konva.interactionRect.y() - bboxPadding, + width: this.konva.interactionRect.width() * this.konva.interactionRect.scaleX() + bboxPadding * 2, + height: this.konva.interactionRect.height() * this.konva.interactionRect.scaleY() + bboxPadding * 2, + strokeWidth: onePixel, + }); + this.konva.transformer.forceUpdate(); + } + + async _renderObject(obj: LayerEntity['objects'][number], force = false): Promise { if (obj.type === 'brush_line') { let brushLine = this.objects.get(obj.id); assert(brushLine instanceof CanvasBrushLine || brushLine === undefined); if (!brushLine) { + console.log('creating new brush line'); brushLine = new CanvasBrushLine(obj); this.objects.set(brushLine.id, brushLine); this.konva.objectGroup.add(brushLine.konva.group); return true; } else { - if (brushLine.update(obj, force)) { + console.log('updating brush line'); + if (await brushLine.update(obj, force)) { return true; } } @@ -320,7 +471,7 @@ export class CanvasLayer { this.konva.objectGroup.add(eraserLine.konva.group); return true; } else { - if (eraserLine.update(obj, force)) { + if (await eraserLine.update(obj, force)) { return true; } } @@ -358,109 +509,70 @@ export class CanvasLayer { return false; } - updateGroup(didDraw: boolean) { - if (!this.state.isEnabled) { - this.konva.layer.visible(false); - return; - } + async startTransform() { + this.isTransforming = true; - if (didDraw) { - if (this.objects.size > 0) { - this.getBbox(); - } else { - this.offsetX = 0; - this.offsetY = 0; - this.width = 0; - this.height = 0; - this.renderBbox(); - } - } + // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or + // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening + // when the view tool is selected + const listening = this.manager.stateApi.getToolState().selected !== 'view'; - this.konva.layer.visible(true); - this.konva.objectGroup.opacity(this.state.opacity); - const isSelected = this.manager.stateApi.getIsSelected(this.id); - const toolState = this.manager.stateApi.getToolState(); + this.konva.layer.listening(listening); + this.konva.interactionRect.listening(listening); + this.konva.transformer.listening(listening); - const isMoving = toolState.selected === 'move' && isSelected; + // The transformer transforms the interaction rect, not the object group + this.konva.transformer.nodes([this.konva.interactionRect]); - this.konva.layer.listening(toolState.isTransforming || isMoving); - this.konva.transformer.listening(toolState.isTransforming); - this.konva.bbox.visible(isMoving); - this.konva.interactionRect.listening(toolState.isTransforming || isMoving); - - if (this.objects.size === 0) { - // If the layer is totally empty, reset the cache and bail out. - this.konva.transformer.nodes([]); - if (this.konva.objectGroup.isCached()) { - this.konva.objectGroup.clearCache(); - } - } else if (isSelected && toolState.isTransforming) { - // When the layer is selected and being moved, we should always cache it. - // We should update the cache if we drew to the layer. - if (!this.konva.objectGroup.isCached() || didDraw) { - // this.konva.objectGroup.cache(); - } - // Activate the transformer - it *must* be transforming the interactionRect, not the group! - this.konva.transformer.nodes([this.konva.interactionRect]); - this.konva.transformer.forceUpdate(); - this.konva.transformer.visible(true); - } else if (toolState.selected === 'move') { - // When the layer is selected and being moved, we should always cache it. - // We should update the cache if we drew to the layer. - if (!this.konva.objectGroup.isCached() || didDraw) { - // this.konva.objectGroup.cache(); - } - // Activate the transformer - this.konva.transformer.nodes([]); - this.konva.transformer.forceUpdate(); - this.konva.transformer.visible(false); - } else if (isSelected) { - // If the layer is selected but not using the move tool, we don't want the layer to be listening. - // The transformer also does not need to be active. - this.konva.transformer.nodes([]); - if (isDrawingTool(toolState.selected)) { - // 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.konva.objectGroup.isCached()) { - this.konva.objectGroup.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.konva.objectGroup.isCached() || didDraw) { - // this.konva.objectGroup.cache(); - } - } - } else if (!isSelected) { - // Unselected layers should not be listening - // The transformer also does not need to be active. - this.konva.transformer.nodes([]); - // Update the layer's cache if it's not already cached or we drew to it. - if (!this.konva.objectGroup.isCached() || didDraw) { - // this.konva.objectGroup.cache(); - } - } + // Hide the bbox rect, the transformer will has its own bbox + this.konva.bbox.visible(false); } - renderBbox() { - const toolState = this.manager.stateApi.getToolState(); - if (toolState.isTransforming) { - return; - } - const isSelected = this.manager.stateApi.getIsSelected(this.id); - const hasBbox = this.width !== 0 && this.height !== 0; - this.konva.bbox.visible(hasBbox && isSelected && toolState.selected === 'move'); - this.konva.interactionRect.visible(hasBbox); - this.updatePosition(); + async resetScale() { + this.konva.objectGroup.scaleX(1); + this.konva.objectGroup.scaleY(1); + this.konva.bbox.scaleX(1); + this.konva.bbox.scaleY(1); + this.konva.interactionRect.scaleX(1); + this.konva.interactionRect.scaleY(1); } - private _getBbox() { + async applyTransform() { + this.isTransforming = false; + const objectGroupClone = this.konva.objectGroup.clone(); + const rect = { + x: this.konva.interactionRect.x(), + y: this.konva.interactionRect.y(), + width: this.konva.interactionRect.width() * this.konva.interactionRect.scaleX(), + height: this.konva.interactionRect.height() * this.konva.interactionRect.scaleY(), + }; + const blob = await konvaNodeToBlob(objectGroupClone, rect); + previewBlob(blob, 'transformed layer'); + const imageDTO = await uploadImage(blob, `${this.id}_transform.png`, 'other', true, true); + const { dispatch } = getStore(); + dispatch(layerRasterized({ id: this.id, imageDTO, position: this.konva.interactionRect.position() })); + this.isTransforming = false; + this.resetScale(); + } + + async cancelTransform() { + this.isTransforming = false; + this.resetScale(); + await this.updatePosition({ position: this.state.position }); + await this.updateBbox(); + await this.updateInteraction({ + toolState: this.manager.stateApi.getToolState(), + isSelected: this.manager.stateApi.getIsSelected(this.id), + }); + } + + getBbox = debounce(() => { if (this.objects.size === 0) { this.offsetX = 0; this.offsetY = 0; this.width = 0; this.height = 0; - this.renderBbox(); + this.updateBbox(); return; } @@ -482,18 +594,8 @@ export class CanvasLayer { this.offsetY = rect.y; this.width = rect.width; this.height = rect.height; - // if (rect.width === 0 || rect.height === 0) { - // this.bbox = CanvasLayer.DEFAULT_BBOX_RECT; - // } else { - // this.bbox = { - // x: rect.x, - // y: rect.y, - // width: rect.width, - // height: rect.height, - // }; - // } this.logBbox('new bbox from client rect'); - this.renderBbox(); + this.updateBbox(); return; } @@ -523,11 +625,11 @@ export class CanvasLayer { this.height = 0; } this.logBbox('new bbox from worker'); - this.renderBbox(); + this.updateBbox(); clone.destroy(); } ); - } + }, CanvasManager.BBOX_DEBOUNCE_MS); logBbox(msg: string = 'bbox') { console.log(msg, { @@ -539,4 +641,13 @@ export class CanvasLayer { height: this.height, }); } + + getLayerRect() { + return { + x: this.state.position.x + this.offsetX, + y: this.state.position.y + this.offsetY, + width: this.width, + height: this.height, + }; + } } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts index a294f71506..fa1caf11b4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts @@ -16,6 +16,7 @@ import { $lastProgressEvent, $shouldShowStagedImage } from 'features/controlLaye import type { CanvasV2State, GenerationMode } from 'features/controlLayers/store/types'; import type Konva from 'konva'; import { atom } from 'nanostores'; +import type { Logger } from 'roarr'; import { getImageDTO as defaultGetImageDTO, uploadImage as defaultUploadImage } from 'services/api/endpoints/images'; import type { ImageCategory, ImageDTO } from 'services/api/types'; import { assert } from 'tsafe'; @@ -32,9 +33,6 @@ import { CanvasStateApi } from './CanvasStateApi'; import { CanvasTool } from './CanvasTool'; import { setStageEventHandlers } from './events'; -const log = logger('canvas'); -const workerLog = logger('worker'); - // type Extents = { // minX: number; // minY: number; @@ -63,17 +61,12 @@ type Util = { ) => Promise; }; -const $canvasManager = atom(null); -export function getCanvasManager() { - const nodeManager = $canvasManager.get(); - assert(nodeManager !== null, 'Node manager not initialized'); - return nodeManager; -} -export function setCanvasManager(nodeManager: CanvasManager) { - $canvasManager.set(nodeManager); -} +export const $canvasManager = atom(null); export class CanvasManager { + private static BBOX_PADDING_PX = 5; + static BBOX_DEBOUNCE_MS = 300; + stage: Konva.Stage; container: HTMLDivElement; controlAdapters: Map; @@ -86,6 +79,11 @@ export class CanvasManager { preview: CanvasPreview; background: CanvasBackground; + log: Logger; + workerLog: Logger; + + onTransform: ((isTransforming: boolean) => void) | null; + private store: Store; private isFirstRender: boolean; private prevState: CanvasV2State; @@ -106,6 +104,9 @@ export class CanvasManager { this.prevState = this.stateApi.getState(); this.isFirstRender = true; + this.log = logger('canvas'); + this.workerLog = logger('worker'); + this.util = { getImageDTO, uploadImage, @@ -138,9 +139,9 @@ export class CanvasManager { const { type, data } = event.data; if (type === 'log') { if (data.ctx) { - workerLog[data.level](data.ctx, data.message); + this.workerLog[data.level](data.ctx, data.message); } else { - workerLog[data.level](data.message); + this.workerLog[data.level](data.message); } } else if (type === 'extents') { const task = this.tasks.get(data.id); @@ -151,11 +152,17 @@ export class CanvasManager { } }; this.worker.onerror = (event) => { - log.error({ message: event.message }, 'Worker error'); + this.log.error({ message: event.message }, 'Worker error'); }; this.worker.onmessageerror = () => { - log.error('Worker message error'); + this.log.error('Worker message error'); }; + this.onTransform = null; + } + + getLogger(namespace: string) { + const managerNamespace = this.log.getContext().namespace; + return this.log.child({ namespace: `${managerNamespace}.${namespace}` }); } requestBbox(data: Omit, onComplete: (extents: Extents | null) => void) { @@ -172,27 +179,6 @@ export class CanvasManager { await this.initialImage.render(this.stateApi.getInitialImageState()); } - async renderLayers() { - const { entities } = this.stateApi.getLayersState(); - - for (const canvasLayer of this.layers.values()) { - if (!entities.find((l) => l.id === canvasLayer.id)) { - canvasLayer.destroy(); - this.layers.delete(canvasLayer.id); - } - } - - for (const entity of entities) { - let adapter = this.layers.get(entity.id); - if (!adapter) { - adapter = new CanvasLayer(entity, this); - this.layers.set(adapter.id, adapter); - this.stage.add(adapter.konva.layer); - } - await adapter.render(entity); - } - } - async renderRegions() { const { entities } = this.stateApi.getRegionsState(); @@ -245,9 +231,9 @@ export class CanvasManager { } } - renderBboxes() { + syncStageScale() { for (const layer of this.layers.values()) { - layer.renderBbox(); + layer.syncStageScale(); } } @@ -283,22 +269,84 @@ export class CanvasManager { this.background.render(); } + getTransformingLayer() { + return Array.from(this.layers.values()).find((layer) => layer.isTransforming); + } + + getIsTransforming() { + return Boolean(this.getTransformingLayer()); + } + + startTransform() { + if (this.getIsTransforming()) { + return; + } + const layer = this.getSelectedEntityAdapter(); + assert(layer instanceof CanvasLayer, 'No selected layer'); + layer.startTransform(); + this.onTransform?.(true); + } + + applyTransform() { + const layer = this.getTransformingLayer(); + if (layer) { + layer.applyTransform(); + } + this.onTransform?.(false); + } + + cancelTransform() { + const layer = this.getTransformingLayer(); + if (layer) { + layer.cancelTransform(); + } + this.onTransform?.(false); + } + render = async () => { const state = this.stateApi.getState(); if (this.prevState === state && !this.isFirstRender) { - log.trace('No changes detected, skipping render'); + this.log.trace('No changes detected, skipping render'); return; } + if (this.isFirstRender || state.layers.entities !== this.prevState.layers.entities) { + this.log.debug('Rendering layers'); + + for (const canvasLayer of this.layers.values()) { + if (!state.layers.entities.find((l) => l.id === canvasLayer.id)) { + this.log.debug(`Destroying deleted layer ${canvasLayer.id}`); + canvasLayer.destroy(); + this.layers.delete(canvasLayer.id); + } + } + + for (const entityState of state.layers.entities) { + let adapter = this.layers.get(entityState.id); + if (!adapter) { + this.log.debug(`Creating layer layer ${entityState.id}`); + adapter = new CanvasLayer(entityState, this); + this.layers.set(adapter.id, adapter); + this.stage.add(adapter.konva.layer); + } + await adapter.update({ + state: entityState, + toolState: state.tool, + isSelected: state.selectedEntityIdentifier?.id === entityState.id, + }); + } + } + if ( this.isFirstRender || - state.layers.entities !== this.prevState.layers.entities || state.tool.selected !== this.prevState.tool.selected || state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id ) { - log.debug('Rendering layers'); - await this.renderLayers(); + this.log.debug('Updating interaction'); + for (const layer of this.layers.values()) { + layer.updateInteraction({ toolState: state.tool, isSelected: state.selectedEntityIdentifier?.id === layer.id }); + } } if ( @@ -308,7 +356,7 @@ export class CanvasManager { state.tool.selected !== this.prevState.tool.selected || state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id ) { - log.debug('Rendering initial image'); + this.log.debug('Rendering initial image'); await this.renderInitialImage(); } @@ -319,7 +367,7 @@ export class CanvasManager { state.tool.selected !== this.prevState.tool.selected || state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id ) { - log.debug('Rendering regions'); + this.log.debug('Rendering regions'); await this.renderRegions(); } @@ -330,7 +378,7 @@ export class CanvasManager { state.tool.selected !== this.prevState.tool.selected || state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id ) { - log.debug('Rendering inpaint mask'); + this.log.debug('Rendering inpaint mask'); await this.renderInpaintMask(); } @@ -340,7 +388,7 @@ export class CanvasManager { state.tool.selected !== this.prevState.tool.selected || state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id ) { - log.debug('Rendering control adapters'); + this.log.debug('Rendering control adapters'); await this.renderControlAdapters(); } @@ -350,7 +398,7 @@ export class CanvasManager { state.tool.selected !== this.prevState.tool.selected || state.session.isActive !== this.prevState.session.isActive ) { - log.debug('Rendering generation bbox'); + this.log.debug('Rendering generation bbox'); await this.preview.bbox.render(); } @@ -360,12 +408,12 @@ export class CanvasManager { state.controlAdapters !== this.prevState.controlAdapters || state.regions !== this.prevState.regions ) { - // log.debug('Updating entity bboxes'); + // this.log.debug('Updating entity bboxes'); // debouncedUpdateBboxes(stage, canvasV2.layers, canvasV2.controlAdapters, canvasV2.regions, onBboxChanged); } if (this.isFirstRender || state.session !== this.prevState.session) { - log.debug('Rendering staging area'); + this.log.debug('Rendering staging area'); await this.preview.stagingArea.render(); } @@ -377,7 +425,7 @@ export class CanvasManager { state.inpaintMask !== this.prevState.inpaintMask || state.selectedEntityIdentifier?.id !== this.prevState.selectedEntityIdentifier?.id ) { - log.debug('Arranging entities'); + this.log.debug('Arranging entities'); await this.arrangeEntities(); } @@ -389,7 +437,7 @@ export class CanvasManager { }; initialize = () => { - log.debug('Initializing renderer'); + this.log.debug('Initializing renderer'); this.stage.container(this.container); const cleanupListeners = setStageEventHandlers(this); @@ -405,24 +453,24 @@ export class CanvasManager { // When we this flag, we need to render the staging area $shouldShowStagedImage.subscribe(async (shouldShowStagedImage, prevShouldShowStagedImage) => { if (shouldShowStagedImage !== prevShouldShowStagedImage) { - log.debug('Rendering staging area'); + this.log.debug('Rendering staging area'); await this.preview.stagingArea.render(); } }); $lastProgressEvent.subscribe(async (lastProgressEvent, prevLastProgressEvent) => { if (lastProgressEvent !== prevLastProgressEvent) { - log.debug('Rendering progress image'); + this.log.debug('Rendering progress image'); await this.preview.progressPreview.render(lastProgressEvent); } }); - log.debug('First render of konva stage'); + this.log.debug('First render of konva stage'); this.preview.tool.render(); this.render(); return () => { - log.debug('Cleaning up konva renderer'); + this.log.debug('Cleaning up konva renderer'); unsubscribeRenderer(); cleanupListeners(); $shouldShowStagedImage.off(); @@ -430,6 +478,19 @@ export class CanvasManager { }; }; + getStageScale(): number { + // The stage is never scaled differently in x and y + return this.stage.scaleX(); + } + + getScaledPixel(): number { + return 1 / this.getStageScale(); + } + + getScaledBboxPadding(): number { + return CanvasManager.BBOX_PADDING_PX / this.getStageScale(); + } + getSelectedEntityAdapter = (): CanvasLayer | CanvasRegion | CanvasControlAdapter | CanvasInpaintMask | null => { const state = this.stateApi.getState(); const identifier = state.selectedEntityIdentifier; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts index d3a94b13ce..6b54330f6a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts @@ -185,7 +185,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { if (e.evt.shiftKey && lastLinePoint) { // Create a straight line from the last line point if (selectedEntityAdapter.getDrawingBuffer()) { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } await selectedEntityAdapter.setDrawingBuffer({ id: getBrushLineId(selectedEntityAdapter.id, uuidv4()), @@ -203,7 +203,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { }); } else { if (selectedEntityAdapter.getDrawingBuffer()) { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } await selectedEntityAdapter.setDrawingBuffer({ id: getBrushLineId(selectedEntityAdapter.id, uuidv4()), @@ -222,7 +222,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { if (e.evt.shiftKey && lastLinePoint) { // Create a straight line from the last line point if (selectedEntityAdapter.getDrawingBuffer()) { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } await selectedEntityAdapter.setDrawingBuffer({ id: getBrushLineId(selectedEntityAdapter.id, uuidv4()), @@ -239,7 +239,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { }); } else { if (selectedEntityAdapter.getDrawingBuffer()) { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } await selectedEntityAdapter.setDrawingBuffer({ id: getEraserLineId(selectedEntityAdapter.id, uuidv4()), @@ -254,7 +254,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { if (toolState.selected === 'rect') { if (selectedEntityAdapter.getDrawingBuffer()) { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } await selectedEntityAdapter.setDrawingBuffer({ id: getRectShapeId(selectedEntityAdapter.id, uuidv4()), @@ -290,7 +290,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { if (toolState.selected === 'brush') { const drawingBuffer = selectedEntityAdapter.getDrawingBuffer(); if (drawingBuffer?.type === 'brush_line') { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } else { await selectedEntityAdapter.setDrawingBuffer(null); } @@ -299,7 +299,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { if (toolState.selected === 'eraser') { const drawingBuffer = selectedEntityAdapter.getDrawingBuffer(); if (drawingBuffer?.type === 'eraser_line') { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } else { await selectedEntityAdapter.setDrawingBuffer(null); } @@ -308,7 +308,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { if (toolState.selected === 'rect') { const drawingBuffer = selectedEntityAdapter.getDrawingBuffer(); if (drawingBuffer?.type === 'rect_shape') { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } else { await selectedEntityAdapter.setDrawingBuffer(null); } @@ -354,7 +354,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { } } else { if (selectedEntityAdapter.getDrawingBuffer()) { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } await selectedEntityAdapter.setDrawingBuffer({ id: getBrushLineId(selectedEntityAdapter.id, uuidv4()), @@ -386,7 +386,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { } } else { if (selectedEntityAdapter.getDrawingBuffer()) { - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } await selectedEntityAdapter.setDrawingBuffer({ id: getEraserLineId(selectedEntityAdapter.id, uuidv4()), @@ -437,16 +437,16 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') { drawingBuffer.points.push(pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') { drawingBuffer.points.push(pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect_shape') { drawingBuffer.width = pos.x - selectedEntity.position.x - drawingBuffer.x; drawingBuffer.height = pos.y - selectedEntity.position.y - drawingBuffer.y; await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); - selectedEntityAdapter.finalizeDrawingBuffer(); + await selectedEntityAdapter.finalizeDrawingBuffer(); } } @@ -496,7 +496,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { scale: newScale, }); manager.background.render(); - manager.renderBboxes(); + manager.syncStageScale(); } } manager.preview.tool.render(); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts index 15b816f8e7..b302a00ba7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts @@ -66,6 +66,7 @@ const initialState: CanvasV2State = { eraser: { width: 50, }, + isTransforming: false, }, bbox: { rect: { x: 0, y: 0, width: 512, height: 512 }, @@ -194,7 +195,6 @@ export const { allEntitiesDeleted, clipToBboxChanged, canvasReset, - toolIsTransformingChanged, // bbox bboxChanged, bboxScaledSizeChanged, @@ -226,6 +226,7 @@ export const { layerBrushLineAdded, layerEraserLineAdded, layerRectShapeAdded, + layerRasterized, // IP Adapters ipaAdded, ipaRecalled, @@ -396,3 +397,6 @@ export const sessionRequested = createAction(`${canvasV2Slice.name}/sessionReque export const sessionStagingAreaImageAccepted = createAction<{ index: number }>( `${canvasV2Slice.name}/sessionStagingAreaImageAccepted` ); +export const transformationApplied = createAction( + `${canvasV2Slice.name}/transformationApplied` +); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/layersReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/layersReducers.ts index f0cf91e1d0..5d538bd821 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/layersReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/layersReducers.ts @@ -34,8 +34,6 @@ export const layersReducers = { id, type: 'layer', isEnabled: true, - bbox: null, - bboxNeedsUpdate: false, objects: [], opacity: 1, position: { x: 0, y: 0 }, @@ -57,8 +55,6 @@ export const layersReducers = { id, type: 'layer', isEnabled: true, - bbox: null, - bboxNeedsUpdate: true, objects: [imageObject], opacity: 1, position: { x: position.x + offsetX, y: position.y + offsetY }, @@ -100,8 +96,6 @@ export const layersReducers = { if (!layer) { return; } - layer.bbox = bbox; - layer.bboxNeedsUpdate = false; if (bbox === null) { // TODO(psyche): Clear objects when bbox is cleared - right now this doesn't work bc bbox calculation for layers // doesn't work - always returns null @@ -116,8 +110,6 @@ export const layersReducers = { } layer.isEnabled = true; layer.objects = []; - layer.bbox = null; - layer.bboxNeedsUpdate = false; state.layers.imageCache = null; layer.position = { x: 0, y: 0 }; }, @@ -183,7 +175,6 @@ export const layersReducers = { } layer.objects.push(brushLine); - layer.bboxNeedsUpdate = true; state.layers.imageCache = null; }, layerEraserLineAdded: (state, action: PayloadAction<{ id: string; eraserLine: EraserLine }>) => { @@ -194,7 +185,6 @@ export const layersReducers = { } layer.objects.push(eraserLine); - layer.bboxNeedsUpdate = true; state.layers.imageCache = null; }, layerRectShapeAdded: (state, action: PayloadAction<{ id: string; rectShape: RectShape }>) => { @@ -205,7 +195,6 @@ export const layersReducers = { } layer.objects.push(rectShape); - layer.bboxNeedsUpdate = true; state.layers.imageCache = null; }, layerScaled: (state, action: PayloadAction) => { @@ -235,7 +224,6 @@ export const layersReducers = { } layer.position.x = Math.round(position.x); layer.position.y = Math.round(position.y); - layer.bboxNeedsUpdate = true; state.layers.imageCache = null; }, layerImageAdded: { @@ -254,7 +242,6 @@ export const layersReducers = { imageObject.y = pos.y; } layer.objects.push(imageObject); - layer.bboxNeedsUpdate = true; state.layers.imageCache = null; }, prepare: (payload: ImageObjectAddedArg & { pos?: { x: number; y: number } }) => ({ @@ -265,6 +252,16 @@ export const layersReducers = { const { imageDTO } = action.payload; state.layers.imageCache = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; }, + layerRasterized: (state, action: PayloadAction<{ id: string; imageDTO: ImageDTO; position: Coordinate }>) => { + const { id, imageDTO, position } = action.payload; + const layer = selectLayer(state, id); + if (!layer) { + return; + } + layer.objects = [imageDTOToImageObject(id, uuidv4(), imageDTO)]; + layer.position = position; + state.layers.imageCache = null; + }, } satisfies SliceCaseReducers; const scalePoints = (points: number[], scaleX: number, scaleY: number) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts index 3724f4942b..c1f14d7df4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts @@ -20,7 +20,4 @@ export const toolReducers = { toolBufferChanged: (state, action: PayloadAction) => { state.tool.selectedBuffer = action.payload; }, - toolIsTransformingChanged: (state, action: PayloadAction) => { - state.tool.isTransforming = action.payload; - }, } satisfies SliceCaseReducers; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index a6aa5936f8..c23708b995 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -579,8 +579,6 @@ export const zLayerEntity = z.object({ type: z.literal('layer'), isEnabled: z.boolean(), position: zCoordinate, - bbox: zRect.nullable(), - bboxNeedsUpdate: z.boolean(), opacity: zOpacity, objects: z.array(zRenderableObject), }); @@ -850,7 +848,6 @@ export type CanvasV2State = { brush: { width: number }; eraser: { width: number }; fill: RgbaColor; - isTransforming: boolean; }; settings: { imageSmoothing: boolean; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 43b6347b5f..9770cfd3de 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -590,11 +590,14 @@ export const uploadImage = async ( blob: Blob, fileName: string, image_category: ImageCategory, - is_intermediate: boolean + is_intermediate: boolean, + crop_visible: boolean = false ): Promise => { const { dispatch } = getStore(); const file = new File([blob], fileName, { type: 'image/png' }); - const req = dispatch(imagesApi.endpoints.uploadImage.initiate({ file, image_category, is_intermediate })); + const req = dispatch( + imagesApi.endpoints.uploadImage.initiate({ file, image_category, is_intermediate, crop_visible }) + ); req.reset(); return await req.unwrap(); };