From 3942e2a501c663f4046cd026699411b73cb09493 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 1 Sep 2024 12:49:13 +1000 Subject: [PATCH] docs(ui): docstrings for classes (wip) --- .../konva/CanvasBackgroundModule.ts | 76 ++-- .../controlLayers/konva/CanvasBboxModule.ts | 369 ++++++++++-------- .../konva/CanvasBrushToolPreview.ts | 13 +- .../controlLayers/konva/CanvasCacheModule.ts | 27 ++ .../konva/CanvasColorPickerToolPreview.ts | 35 +- .../konva/CanvasCompositorModule.ts | 232 +++++++---- .../konva/CanvasEntityLayerAdapter.ts | 26 ++ 7 files changed, 511 insertions(+), 267 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts index e349f7d51d..980cccc35c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts @@ -15,6 +15,11 @@ const DEFAULT_CONFIG: CanvasBackgroundModuleConfig = { GRID_LINE_COLOR_FINE: getArbitraryBaseColor(18), }; +/** + * Renders a background grid on the canvas, where the grid spacing changes based on the stage scale. + * + * The grid is only visible when the dynamic grid setting is enabled. + */ export class CanvasBackgroundModule extends CanvasModuleBase { readonly type = 'background'; @@ -22,13 +27,19 @@ export class CanvasBackgroundModule extends CanvasModuleBase { path: string[]; parent: CanvasManager; manager: CanvasManager; - subscriptions = new Set<() => void>(); log: Logger; + subscriptions = new Set<() => void>(); config: CanvasBackgroundModuleConfig = DEFAULT_CONFIG; + /** + * The Konva objects that make up the background grid: + * - A layer to hold the grid lines + * - An array of grid lines + */ konva: { layer: Konva.Layer; + lines: Konva.Line[]; }; constructor(manager: CanvasManager) { @@ -41,11 +52,20 @@ export class CanvasBackgroundModule extends CanvasModuleBase { this.log.debug('Creating module'); - this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }) }; + this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }), lines: [] }; + /** + * The background grid should be rendered when the stage attributes change: + * - scale + * - position + * - size + */ this.subscriptions.add(this.manager.stateApi.$stageAttrs.listen(this.render)); } + /** + * Renders the background grid. + */ render = () => { const settings = this.manager.stateApi.getSettings(); @@ -56,11 +76,10 @@ export class CanvasBackgroundModule extends CanvasModuleBase { this.konva.layer.visible(true); - this.konva.layer.zIndex(0); const scale = this.manager.stage.getScale(); const { x, y } = this.manager.stage.getPosition(); const { width, height } = this.manager.stage.getSize(); - const gridSpacing = this.getGridSpacing(scale); + const gridSpacing = CanvasBackgroundModule.getGridSpacing(scale); const stageRect = { x1: 0, y1: 0, @@ -99,41 +118,44 @@ export class CanvasBackgroundModule extends CanvasModuleBase { let _y = 0; this.konva.layer.destroyChildren(); + this.konva.lines = []; for (let i = 0; i < xSteps; i++) { _x = gridFullRect.x1 + i * gridSpacing; - this.konva.layer.add( - new Konva.Line({ - x: _x, - y: gridFullRect.y1, - points: [0, 0, 0, ySize], - stroke: _x % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE, - strokeWidth, - listening: false, - }) - ); + const line = new Konva.Line({ + x: _x, + y: gridFullRect.y1, + points: [0, 0, 0, ySize], + stroke: _x % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE, + strokeWidth, + listening: false, + }); + this.konva.lines.push(line); + this.konva.layer.add(line); } for (let i = 0; i < ySteps; i++) { _y = gridFullRect.y1 + i * gridSpacing; - this.konva.layer.add( - new Konva.Line({ - x: gridFullRect.x1, - y: _y, - points: [0, 0, xSize, 0], - stroke: _y % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE, - strokeWidth, - listening: false, - }) - ); + const line = new Konva.Line({ + x: gridFullRect.x1, + y: _y, + points: [0, 0, xSize, 0], + stroke: _y % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE, + strokeWidth, + listening: false, + }); + this.konva.lines.push(line); + this.konva.layer.add(line); } }; /** - * Gets the grid spacing. The value depends on the stage scale - at higher scales, the grid spacing is smaller. + * Gets the grid line spacing for the dynamic grid. + * + * The value depends on the stage scale - at higher scales, the grid spacing is smaller. + * * @param scale The stage scale - * @returns The grid spacing based on the stage scale */ - getGridSpacing = (scale: number): number => { + static getGridSpacing = (scale: number): number => { if (scale >= 2) { return 8; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts index a736411627..2c76d0cde9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts @@ -2,7 +2,7 @@ import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMult import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import type { Rect } from 'features/controlLayers/store/types'; +import type { Coordinate, Rect } from 'features/controlLayers/store/types'; import Konva from 'konva'; import { atom } from 'nanostores'; import type { Logger } from 'roarr'; @@ -21,6 +21,9 @@ const ALL_ANCHORS: string[] = [ const CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; const NO_ANCHORS: string[] = []; +/** + * Renders the bounding box. The bounding box can be transformed by the user. + */ export class CanvasBboxModule extends CanvasModuleBase { readonly type = 'bbox'; @@ -32,12 +35,24 @@ export class CanvasBboxModule extends CanvasModuleBase { subscriptions: Set<() => void> = new Set(); + /** + * The Konva objects that make up the bbox: + * - A group to hold all the objects + * - A transformer to allow the bbox to be transformed + * - A transparent rect so the transformer has something to transform + */ konva: { group: Konva.Group; - rect: Konva.Rect; transformer: Konva.Transformer; + proxyRect: Konva.Rect; }; + /** + * Buffer to store the last aspect ratio of the bbox. When the users holds shift while transforming the bbox, this is + * used to lock the aspect ratio. + */ + $aspectRatioBuffer = atom(0); + constructor(manager: CanvasManager) { super(); this.id = getPrefixedId(this.type); @@ -48,16 +63,15 @@ export class CanvasBboxModule extends CanvasModuleBase { this.log.debug('Creating bbox module'); - // Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when - // transforming the bbox. + // Set the initial aspect ratio buffer per app state. const bbox = this.manager.stateApi.getBbox(); - const $aspectRatioBuffer = atom(bbox.rect.width / bbox.rect.height); + this.$aspectRatioBuffer.set(bbox.rect.width / bbox.rect.height); this.konva = { - group: new Konva.Group({ name: `${this.type}:group`, listening: false }), - // Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully - // transparent rect for this purpose. - rect: new Konva.Rect({ + group: new Konva.Group({ name: `${this.type}:group`, listening: true }), + // We will use a Konva.Transformer for the generation bbox. Transformers need some shape to transform, so we will + // create a transparent rect for this purpose. + proxyRect: new Konva.Rect({ name: `${this.type}:rect`, listening: false, strokeEnabled: false, @@ -83,173 +97,43 @@ export class CanvasBboxModule extends CanvasModuleBase { anchorCornerRadius: 3, shiftBehavior: 'none', // we will implement our own shift behavior centeredScaling: false, - anchorStyleFunc: (anchor) => { - // Make the x/y resize anchors little bars - if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) { - anchor.height(8); - anchor.offsetY(4); - anchor.width(30); - anchor.offsetX(15); - } - if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) { - anchor.height(30); - anchor.offsetY(15); - anchor.width(8); - anchor.offsetX(4); - } - }, - anchorDragBoundFunc: (_oldAbsPos, newAbsPos) => { - // This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed - // to konva's internal coordinate system. - const stage = this.konva.transformer.getStage(); - 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 = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 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. - const stageAbsPos = stage.getAbsolutePosition(); - // The offset is the remainder of the stage's absolute position divided by the scaled grid size. - const offsetX = stageAbsPos.x % scaledGridSize; - const offsetY = stageAbsPos.y % scaledGridSize; - // Finally, calculate the position by rounding to the grid and adding the offset. - return { - x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX, - y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY, - }; - }, + anchorStyleFunc: this.anchorStyleFunc, + anchorDragBoundFunc: this.anchorDragBoundFunc, }), }; - this.konva.rect.on('dragmove', () => { - const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64; - const bbox = this.manager.stateApi.getBbox(); - const bboxRect: Rect = { - ...bbox.rect, - x: roundToMultiple(this.konva.rect.x(), gridSize), - y: roundToMultiple(this.konva.rect.y(), gridSize), - }; - this.konva.rect.setAttrs(bboxRect); - if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) { - this.manager.stateApi.setGenerationBbox(bboxRect); - } - }); - this.konva.transformer.on('transform', () => { - // In the transform callback, we calculate the bbox's new dims and pos and update the konva object. - // Some special handling is needed depending on the anchor being dragged. - const anchor = this.konva.transformer.getActiveAnchor(); - if (!anchor) { - // Pretty sure we should always have an anchor here? - return; - } + this.konva.proxyRect.on('dragmove', this.onDragMove); + this.konva.transformer.on('transform', this.onTransform); + this.konva.transformer.on('transformend', this.onTransformEnd); - const alt = this.manager.stateApi.$altKey.get(); - const ctrl = this.manager.stateApi.$ctrlKey.get(); - const meta = this.manager.stateApi.$metaKey.get(); - const shift = this.manager.stateApi.$shiftKey.get(); - - // Grid size depends on the modifier keys - let gridSize = ctrl || meta ? 8 : 64; - - // Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the - // 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 (this.manager.stateApi.$altKey.get()) { - gridSize = gridSize * 2; - } - - // The coords should be correct per the anchorDragBoundFunc. - let x = this.konva.rect.x(); - let y = this.konva.rect.y(); - - // Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height - // *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap - // them to the grid. - let width = roundToMultipleMin(this.konva.rect.width() * this.konva.rect.scaleX(), gridSize); - let height = roundToMultipleMin(this.konva.rect.height() * this.konva.rect.scaleY(), gridSize); - - // If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this - // if alt/opt is held - this requires math too big for my brain. - if (shift && CORNER_ANCHORS.includes(anchor) && !alt) { - // Fit the bbox to the last aspect ratio - let fittedWidth = Math.sqrt(width * height * $aspectRatioBuffer.get()); - let fittedHeight = fittedWidth / $aspectRatioBuffer.get(); - fittedWidth = roundToMultipleMin(fittedWidth, gridSize); - fittedHeight = roundToMultipleMin(fittedHeight, gridSize); - - // We need to adjust the x and y coords to have the resize occur from the right origin. - if (anchor === 'top-left') { - // The transform origin is the bottom-right anchor. Both x and y need to be updated. - x = x - (fittedWidth - width); - y = y - (fittedHeight - height); - } - if (anchor === 'top-right') { - // The transform origin is the bottom-left anchor. Only y needs to be updated. - y = y - (fittedHeight - height); - } - if (anchor === 'bottom-left') { - // The transform origin is the top-right anchor. Only x needs to be updated. - x = x - (fittedWidth - width); - } - // Update the width and height to the fitted dims. - width = fittedWidth; - height = fittedHeight; - } - - const bboxRect = { - x: Math.round(x), - y: Math.round(y), - width, - height, - }; - - // Update the bboxRect's attrs directly with the new transform, and reset its scale to 1. - // TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly. - // Gotta be a way to avoid setting it twice... - this.konva.rect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 }); - - // Update the bbox in internal state. - this.manager.stateApi.setGenerationBbox(bboxRect); - - // 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. - if (!shift) { - $aspectRatioBuffer.set(bboxRect.width / bboxRect.height); - } - }); - - this.konva.transformer.on('transformend', () => { - // Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held, - // we have the correct aspect ratio to start from. - $aspectRatioBuffer.set(this.konva.rect.width() / this.konva.rect.height()); - }); - - // The transformer will always be transforming the dummy rect - this.konva.transformer.nodes([this.konva.rect]); - this.konva.group.add(this.konva.rect); + // The transformer will always be transforming the proxy rect + this.konva.transformer.nodes([this.konva.proxyRect]); + this.konva.group.add(this.konva.proxyRect); this.konva.group.add(this.konva.transformer); + // We will listen to the tool state to determine if the bbox should be visible or not. this.subscriptions.add(this.manager.stateApi.$tool.listen(this.render)); } + /** + * Renders the bbox. The bbox is only visible when the tool is set to 'bbox'. + */ render = () => { - this.log.trace('Rendering bbox module'); + this.log.trace('Rendering'); - const bbox = this.manager.stateApi.getBbox(); + const { x, y, width, height } = this.manager.stateApi.getBbox().rect; const tool = this.manager.stateApi.$tool.get(); this.konva.group.visible(true); + // We need to reach up to the preview layer to enable/disable listening so that the bbox can be interacted with. this.manager.konva.previewLayer.listening(tool === 'bbox'); - this.konva.group.listening(tool === 'bbox'); - this.konva.rect.setAttrs({ - x: bbox.rect.x, - y: bbox.rect.y, - width: bbox.rect.width, - height: bbox.rect.height, + this.konva.proxyRect.setAttrs({ + x, + y, + width, + height, scaleX: 1, scaleY: 1, listening: tool === 'bbox', @@ -261,8 +145,173 @@ export class CanvasBboxModule extends CanvasModuleBase { }; destroy = () => { - this.log.trace('Destroying bbox module'); + this.log.trace('Destroying module'); this.subscriptions.forEach((unsubscribe) => unsubscribe()); this.konva.group.destroy(); }; + + /** + * Handles the dragmove event on the bbox rect: + * - Snaps the bbox position to the grid (determined by ctrl/meta key) + * - Pushes the new bbox rect into app state + */ + onDragMove = () => { + const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64; + const bbox = this.manager.stateApi.getBbox(); + const bboxRect: Rect = { + ...bbox.rect, + x: roundToMultiple(this.konva.proxyRect.x(), gridSize), + y: roundToMultiple(this.konva.proxyRect.y(), gridSize), + }; + this.konva.proxyRect.setAttrs(bboxRect); + if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) { + this.manager.stateApi.setGenerationBbox(bboxRect); + } + }; + + /** + * Handles the transform event on the bbox transformer: + * - Snaps the bbox dimensions to the grid (determined by ctrl/meta key) + * - Centered scaling when alt is held + * - Aspect ratio locking when shift is held + * - Pushes the new bbox rect into app state + * - Syncs the aspect ratio buffer + */ + onTransform = () => { + // In the transform callback, we calculate the bbox's new dims and pos and update the konva object. + // Some special handling is needed depending on the anchor being dragged. + const anchor = this.konva.transformer.getActiveAnchor(); + if (!anchor) { + // Pretty sure we should always have an anchor here? + return; + } + + const alt = this.manager.stateApi.$altKey.get(); + const ctrl = this.manager.stateApi.$ctrlKey.get(); + const meta = this.manager.stateApi.$metaKey.get(); + const shift = this.manager.stateApi.$shiftKey.get(); + + // Grid size depends on the modifier keys + let gridSize = ctrl || meta ? 8 : 64; + + // Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the + // 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 (this.manager.stateApi.$altKey.get()) { + gridSize = gridSize * 2; + } + + // The coords should be correct per the anchorDragBoundFunc. + let x = this.konva.proxyRect.x(); + let y = this.konva.proxyRect.y(); + + // Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height + // *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap + // them to the grid. + let width = roundToMultipleMin(this.konva.proxyRect.width() * this.konva.proxyRect.scaleX(), gridSize); + let height = roundToMultipleMin(this.konva.proxyRect.height() * this.konva.proxyRect.scaleY(), gridSize); + + // If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this + // if alt/opt is held - this requires math too big for my brain. + if (shift && CORNER_ANCHORS.includes(anchor) && !alt) { + // Fit the bbox to the last aspect ratio + let fittedWidth = Math.sqrt(width * height * this.$aspectRatioBuffer.get()); + let fittedHeight = fittedWidth / this.$aspectRatioBuffer.get(); + fittedWidth = roundToMultipleMin(fittedWidth, gridSize); + fittedHeight = roundToMultipleMin(fittedHeight, gridSize); + + // We need to adjust the x and y coords to have the resize occur from the right origin. + if (anchor === 'top-left') { + // The transform origin is the bottom-right anchor. Both x and y need to be updated. + x = x - (fittedWidth - width); + y = y - (fittedHeight - height); + } + if (anchor === 'top-right') { + // The transform origin is the bottom-left anchor. Only y needs to be updated. + y = y - (fittedHeight - height); + } + if (anchor === 'bottom-left') { + // The transform origin is the top-right anchor. Only x needs to be updated. + x = x - (fittedWidth - width); + } + // Update the width and height to the fitted dims. + width = fittedWidth; + height = fittedHeight; + } + + const bboxRect = { + x: Math.round(x), + y: Math.round(y), + width, + height, + }; + + // Update the bboxRect's attrs directly with the new transform, and reset its scale to 1. + this.konva.proxyRect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 }); + + // Update the bbox in internal state. + this.manager.stateApi.setGenerationBbox(bboxRect); + + // 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. + if (!shift) { + this.$aspectRatioBuffer.set(bboxRect.width / bboxRect.height); + } + }; + + /** + * Handles the transformend event on the bbox transformer: + * - Updates the aspect ratio buffer with the new aspect ratio + */ + onTransformEnd = () => { + // Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held, + // we have the correct aspect ratio to start from. + this.$aspectRatioBuffer.set(this.konva.proxyRect.width() / this.konva.proxyRect.height()); + }; + + /** + * This function is called for each anchor on the transformer. It sets the style of the anchor based on its name. + * We make the x/y resize anchors little bars. + */ + anchorStyleFunc = (anchor: Konva.Rect): void => { + if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) { + anchor.height(8); + anchor.offsetY(4); + anchor.width(30); + anchor.offsetX(15); + } + if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) { + anchor.height(30); + anchor.offsetY(15); + anchor.width(8); + anchor.offsetX(4); + } + }; + + /** + * This function is called for each anchor on the transformer. It sets the drag bounds for the anchor based on the + * stage's position and the grid size. Care is taken to ensure the anchor snaps to the grid correctly. + */ + anchorDragBoundFunc = (oldAbsPos: Coordinate, newAbsPos: Coordinate): Coordinate => { + // This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed + // to konva's internal coordinate system. + const stage = this.konva.transformer.getStage(); + 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 = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 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. + const stageAbsPos = stage.getAbsolutePosition(); + // The offset is the remainder of the stage's absolute position divided by the scaled grid size. + const offsetX = stageAbsPos.x % scaledGridSize; + const offsetY = stageAbsPos.y % scaledGridSize; + // Finally, calculate the position by rounding to the grid and adding the offset. + return { + x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX, + y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY, + }; + }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushToolPreview.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushToolPreview.ts index c4201e71ab..e363e74849 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushToolPreview.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushToolPreview.ts @@ -22,6 +22,9 @@ const DEFAULT_CONFIG: BrushToolPreviewConfig = { BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)', }; +/** + * Renders a preview of the brush tool on the canvas. + */ export class CanvasBrushToolPreview extends CanvasModuleBase { readonly type = 'brush_tool_preview'; @@ -33,6 +36,13 @@ export class CanvasBrushToolPreview extends CanvasModuleBase { config: BrushToolPreviewConfig = DEFAULT_CONFIG; + /** + * The Konva objects that make up the brush tool preview: + * - A group to hold the fill circle and borders + * - A circle to fill the brush area + * - An inner border ring + * - An outer border ring + */ konva: { group: Konva.Group; fillCircle: Konva.Circle; @@ -80,6 +90,7 @@ export class CanvasBrushToolPreview extends CanvasModuleBase { render = () => { const cursorPos = this.manager.stateApi.$lastCursorPos.get(); + // If the cursor position is not available, do not update the brush preview. The tool module will handle visiblity. if (!cursorPos) { return; } @@ -129,7 +140,7 @@ export class CanvasBrushToolPreview extends CanvasModuleBase { }; destroy = () => { - this.log.debug('Destroying brush tool preview module'); + this.log.debug('Destroying module'); this.konva.group.destroy(); }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCacheModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCacheModule.ts index baaced5b2d..d69cf9a302 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCacheModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCacheModule.ts @@ -26,6 +26,10 @@ const DEFAULT_CONFIG: CanvasCacheModuleConfig = { generationModeCacheSize: 100, }; +/** + * A cache module for storing the results of expensive calculations. For example, when we rasterize a layer and upload + * it to the server, we store the resultant image name in this cache for future use. + */ export class CanvasCacheModule extends CanvasModuleBase { readonly type = 'cache'; @@ -37,8 +41,28 @@ export class CanvasCacheModule extends CanvasModuleBase { config: CanvasCacheModuleConfig = DEFAULT_CONFIG; + /** + * A cache for storing image names. Used as a cache for results of layer/canvas/entity exports. For example, when we + * rasterize a layer and upload it to the server, we store the image name in this cache. + * + * The cache key is a hash of the exported entity's state and the export rect. + */ imageNameCache = new LRUCache({ max: this.config.imageNameCacheSize }); + + /** + * A cache for storing canvas elements. Similar to the image name cache, but for canvas elements. The primary use is + * for caching composite layers. For example, the canvas compositor module uses this to store the canvas elements for + * individual raster layers when creating a composite of the layers. + * + * The cache key is a hash of the exported entity's state and the export rect. + */ canvasElementCache = new LRUCache({ max: this.config.canvasElementCacheSize }); + /** + * A cache for the generation mode calculation, which is fairly expensive. + * + * The cache key is a hash of all the objects that contribute to the generation mode calculation (e.g. the composite + * raster layer, the composite inpaint mask, and bounding box), and the value is the generation mode. + */ generationModeCache = new LRUCache({ max: this.config.generationModeCacheSize }); constructor(manager: CanvasManager) { @@ -52,6 +76,9 @@ export class CanvasCacheModule extends CanvasModuleBase { this.log.debug('Creating cache module'); } + /** + * Clears all caches. + */ clearAll = () => { this.canvasElementCache.clear(); this.imageNameCache.clear(); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasColorPickerToolPreview.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasColorPickerToolPreview.ts index f7c6b167bc..9d6f71af14 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasColorPickerToolPreview.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasColorPickerToolPreview.ts @@ -63,6 +63,9 @@ const DEFAULT_CONFIG: ColorPickerToolConfig = { CROSSHAIR_BORDER_COLOR: 'rgba(255,255,255,0.8)', }; +/** + * Renders a preview of the color picker tool on the canvas. + */ export class CanvasColorPickerToolPreview extends CanvasModuleBase { readonly type = 'color_picker_tool_preview'; @@ -74,10 +77,16 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase { config: ColorPickerToolConfig = DEFAULT_CONFIG; + /** + * The Konva objects that make up the color picker tool preview: + * - A group to hold all the objects + * - A ring that shows the candidate and current color + * - A crosshair to help with color selection + */ konva: { group: Konva.Group; - ringNewColor: Konva.Ring; - ringOldColor: Konva.Arc; + ringCandidateColor: Konva.Ring; + ringCurrentColor: Konva.Arc; ringInnerBorder: Konva.Ring; ringOuterBorder: Konva.Ring; crosshairNorthInner: Konva.Line; @@ -98,18 +107,18 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase { this.path = this.manager.buildPath(this); this.log = this.manager.buildLogger(this); - this.log.debug('Creating color picker tool preview module'); + this.log.debug('Creating module'); this.konva = { group: new Konva.Group({ name: `${this.type}:color_picker_group`, listening: false }), - ringNewColor: new Konva.Ring({ - name: `${this.type}:color_picker_new_color_ring`, + ringCandidateColor: new Konva.Ring({ + name: `${this.type}:color_picker_candidate_color_ring`, innerRadius: 0, outerRadius: 0, strokeEnabled: false, }), - ringOldColor: new Konva.Arc({ - name: `${this.type}:color_picker_old_color_arc`, + ringCurrentColor: new Konva.Arc({ + name: `${this.type}:color_picker_current_color_arc`, innerRadius: 0, outerRadius: 0, angle: 180, @@ -164,8 +173,8 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase { }; this.konva.group.add( - this.konva.ringNewColor, - this.konva.ringOldColor, + this.konva.ringCandidateColor, + this.konva.ringCurrentColor, this.konva.ringInnerBorder, this.konva.ringOuterBorder, this.konva.crosshairNorthOuter, @@ -179,9 +188,13 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase { ); } + /** + * Renders the color picker tool preview on the canvas. + */ render = () => { const cursorPos = this.manager.stateApi.$lastCursorPos.get(); + // If the cursor position is not available, do not render the preview. The tool module will handle visibility. if (!cursorPos) { return; } @@ -193,14 +206,14 @@ export class CanvasColorPickerToolPreview extends CanvasModuleBase { const onePixel = this.manager.stage.getScaledPixels(1); const twoPixels = this.manager.stage.getScaledPixels(2); - this.konva.ringNewColor.setAttrs({ + this.konva.ringCandidateColor.setAttrs({ x: cursorPos.x, y: cursorPos.y, fill: rgbColorToString(colorUnderCursor), innerRadius: colorPickerInnerRadius, outerRadius: colorPickerOuterRadius, }); - this.konva.ringOldColor.setAttrs({ + this.konva.ringCurrentColor.setAttrs({ x: cursorPos.x, y: cursorPos.y, fill: rgbColorToString(toolState.fill), diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 7dd55210ec..fb0207177e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -15,6 +15,12 @@ import type { ImageDTO } from 'services/api/types'; import stableHash from 'stable-hash'; import { assert } from 'tsafe'; +/** + * Handles compositing operations: + * - Rasterizing and uploading the composite raster layer + * - Rasterizing and uploading the composite inpaint mask + * - Caclulating the generation mode (which requires the composite raster layer and inpaint mask) + */ export class CanvasCompositorModule extends CanvasModuleBase { readonly type = 'compositor'; @@ -34,6 +40,11 @@ export class CanvasCompositorModule extends CanvasModuleBase { this.log.debug('Creating compositor module'); } + /** + * Gets the entity IDs of all raster layers that should be included in the composite raster layer. + * A raster layer is included if it is enabled and has objects. + * @returns An array of raster layer entity IDs + */ getCompositeRasterLayerEntityIds = (): string[] => { const ids = []; for (const adapter of this.manager.adapters.rasterLayers.values()) { @@ -44,16 +55,35 @@ export class CanvasCompositorModule extends CanvasModuleBase { return ids; }; - getCompositeInpaintMaskEntityIds = (): string[] => { - const ids = []; - for (const adapter of this.manager.adapters.inpaintMasks.values()) { - if (adapter.state.isEnabled && adapter.renderer.hasObjects()) { - ids.push(adapter.id); + /** + * Gets a hash of the composite raster layer, which includes the state of all raster layers that are included in the + * composite plus arbitrary extra data that should contribute to the hash (e.g. a rect). + * @param extra Any extra data to include in the hash + * @returns A hash for the composite raster layer + */ + getCompositeRasterLayerHash = (extra: SerializableObject): string => { + const data: Record = { + extra, + }; + for (const id of this.getCompositeRasterLayerEntityIds()) { + const adapter = this.manager.adapters.rasterLayers.get(id); + if (!adapter) { + this.log.warn({ id }, 'Raster layer adapter not found'); + continue; } + data[id] = adapter.getHashableState(); } - return ids; + return stableHash(data); }; + /** + * Gets a canvas element for the composite raster layer. Only the region defined by the rect is included in the canvas. + * + * If the hash of the composite raster layer is found in the cache, the cached canvas is returned. + * + * @param rect The region to include in the canvas + * @returns A canvas element with the composite raster layer drawn on it + */ getCompositeRasterLayerCanvas = (rect: Rect): HTMLCanvasElement => { const hash = this.getCompositeRasterLayerHash({ rect }); const cachedCanvas = this.manager.cache.canvasElementCache.get(hash); @@ -86,6 +116,99 @@ export class CanvasCompositorModule extends CanvasModuleBase { return canvas; }; + /** + * Rasterizes the composite raster layer and uploads it to the server. + * + * If the hash of the composite raster layer is found in the cache, the cached image DTO is returned. + * + * @param rect The region to include in the rasterized image + * @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO + * @returns A promise that resolves to the uploaded image DTO + */ + rasterizeAndUploadCompositeRasterLayer = async (rect: Rect, saveToGallery: boolean): Promise => { + this.log.trace({ rect }, 'Rasterizing composite raster layer'); + + const canvas = this.getCompositeRasterLayerCanvas(rect); + const blob = await canvasToBlob(canvas); + + if (this.manager._isDebugging) { + previewBlob(blob, 'Composite raster layer canvas'); + } + + return uploadImage(blob, 'composite-raster-layer.png', 'general', !saveToGallery); + }; + + /** + * Gets the image DTO for the composite raster layer. + * + * If the image is found in the cache, the cached image DTO is returned. + * + * @param rect The region to include in the image + * @returns A promise that resolves to the image DTO + */ + getCompositeRasterLayerImageDTO = async (rect: Rect): Promise => { + let imageDTO: ImageDTO | null = null; + + const hash = this.getCompositeRasterLayerHash({ rect }); + const cachedImageName = this.manager.cache.imageNameCache.get(hash); + + if (cachedImageName) { + imageDTO = await getImageDTO(cachedImageName); + if (imageDTO) { + this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image'); + return imageDTO; + } + } + + imageDTO = await this.rasterizeAndUploadCompositeRasterLayer(rect, false); + this.manager.cache.imageNameCache.set(hash, imageDTO.image_name); + return imageDTO; + }; + + /** + * Gets the entity IDs of all inpaint masks that should be included in the composite inpaint mask. + * An inpaint mask is included if it is enabled and has objects. + * @returns An array of inpaint mask entity IDs + */ + getCompositeInpaintMaskEntityIds = (): string[] => { + const ids = []; + for (const adapter of this.manager.adapters.inpaintMasks.values()) { + if (adapter.state.isEnabled && adapter.renderer.hasObjects()) { + ids.push(adapter.id); + } + } + return ids; + }; + + /** + * Gets a hash of the composite inpaint mask, which includes the state of all inpaint masks that are included in the + * composite plus arbitrary extra data that should contribute to the hash (e.g. a rect). + * @param extra Any extra data to include in the hash + * @returns A hash for the composite inpaint mask + */ + getCompositeInpaintMaskHash = (extra: SerializableObject): string => { + const data: Record = { + extra, + }; + for (const id of this.getCompositeInpaintMaskEntityIds()) { + const adapter = this.manager.adapters.inpaintMasks.get(id); + if (!adapter) { + this.log.warn({ id }, 'Inpaint mask adapter not found'); + continue; + } + data[id] = adapter.getHashableState(); + } + return stableHash(data); + }; + + /** + * Gets a canvas element for the composite inpaint mask. Only the region defined by the rect is included in the canvas. + * + * If the hash of the composite inpaint mask is found in the cache, the cached canvas is returned. + * + * @param rect The region to include in the canvas + * @returns A canvas element with the composite inpaint mask drawn on it + */ getCompositeInpaintMaskCanvas = (rect: Rect): HTMLCanvasElement => { const hash = this.getCompositeInpaintMaskHash({ rect }); const cachedCanvas = this.manager.cache.canvasElementCache.get(hash); @@ -118,68 +241,15 @@ export class CanvasCompositorModule extends CanvasModuleBase { return canvas; }; - getCompositeRasterLayerHash = (extra: SerializableObject): string => { - const data: Record = { - extra, - }; - for (const id of this.getCompositeRasterLayerEntityIds()) { - const adapter = this.manager.adapters.rasterLayers.get(id); - if (!adapter) { - this.log.warn({ id }, 'Raster layer adapter not found'); - continue; - } - data[id] = adapter.getHashableState(); - } - return stableHash(data); - }; - - getCompositeInpaintMaskHash = (extra: SerializableObject): string => { - const data: Record = { - extra, - }; - for (const id of this.getCompositeInpaintMaskEntityIds()) { - const adapter = this.manager.adapters.inpaintMasks.get(id); - if (!adapter) { - this.log.warn({ id }, 'Inpaint mask adapter not found'); - continue; - } - data[id] = adapter.getHashableState(); - } - return stableHash(data); - }; - - rasterizeAndUploadCompositeRasterLayer = async (rect: Rect, saveToGallery: boolean) => { - this.log.trace({ rect }, 'Rasterizing composite raster layer'); - - const canvas = this.getCompositeRasterLayerCanvas(rect); - const blob = await canvasToBlob(canvas); - - if (this.manager._isDebugging) { - previewBlob(blob, 'Composite raster layer canvas'); - } - - return uploadImage(blob, 'composite-raster-layer.png', 'general', !saveToGallery); - }; - - getCompositeRasterLayerImageDTO = async (rect: Rect): Promise => { - let imageDTO: ImageDTO | null = null; - - const hash = this.getCompositeRasterLayerHash({ rect }); - const cachedImageName = this.manager.cache.imageNameCache.get(hash); - - if (cachedImageName) { - imageDTO = await getImageDTO(cachedImageName); - if (imageDTO) { - this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image'); - return imageDTO; - } - } - - imageDTO = await this.rasterizeAndUploadCompositeRasterLayer(rect, false); - this.manager.cache.imageNameCache.set(hash, imageDTO.image_name); - return imageDTO; - }; - + /** + * Rasterizes the composite inpaint mask and uploads it to the server. + * + * If the hash of the composite inpaint mask is found in the cache, the cached image DTO is returned. + * + * @param rect The region to include in the rasterized image + * @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO + * @returns A promise that resolves to the uploaded image DTO + */ rasterizeAndUploadCompositeInpaintMask = async (rect: Rect, saveToGallery: boolean) => { this.log.trace({ rect }, 'Rasterizing composite inpaint mask'); @@ -192,6 +262,14 @@ export class CanvasCompositorModule extends CanvasModuleBase { return uploadImage(blob, 'composite-inpaint-mask.png', 'general', !saveToGallery); }; + /** + * Gets the image DTO for the composite inpaint mask. + * + * If the image is found in the cache, the cached image DTO is returned. + * + * @param rect The region to include in the image + * @returns A promise that resolves to the image DTO + */ getCompositeInpaintMaskImageDTO = async (rect: Rect): Promise => { let imageDTO: ImageDTO | null = null; @@ -211,6 +289,24 @@ export class CanvasCompositorModule extends CanvasModuleBase { return imageDTO; }; + /** + * Calculates the generation mode for the current canvas state. This is determined by the transparency of the + * composite raster layer and composite inpaint mask: + * - Composite raster layer is fully transparent -> txt2img + * - Composite raster layer is partially transparent -> outpainting + * - Composite raster layer is opaque & composite inpaint mask is fully transparent -> img2img + * - Composite raster layer is opaque & composite inpaint mask is partially transparent -> inpainting + * + * Definitions: + * - Fully transparent: all pixels have an alpha value of 0. + * - Partially transparent: at least one pixel with an alpha value of 0 & at least one pixel with an alpha value + * greater than 0. + * - Opaque: all pixels have an alpha value greater than 0. + * + * The generation mode is cached to avoid recalculating it when the canvas state has not changed. + * + * @returns The generation mode + */ getGenerationMode(): GenerationMode { const { rect } = this.manager.stateApi.getBbox(); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityLayerAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityLayerAdapter.ts index d28c5bca71..94e086ac1d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityLayerAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntityLayerAdapter.ts @@ -22,6 +22,15 @@ import type { Logger } from 'roarr'; import stableHash from 'stable-hash'; import { assert } from 'tsafe'; +/** + * Handles the rendering for a single raster or control layer entity. + * + * This module has two main components: + * - A transformer, which handles the positioning and interaction state of the layer + * - A renderer, which handles the rendering of the layer's objects + * + * The canvas rendering module interacts with this module to coordinate the rendering of all raster and control layers. + */ export class CanvasEntityLayerAdapter extends CanvasModuleBase { readonly type = 'entity_layer_adapter'; @@ -31,12 +40,29 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase { parent: CanvasManager; log: Logger; + /** + * The last known state of the entity. + */ state: CanvasRasterLayerState | CanvasControlLayerState; + /** + * The Konva nodes that make up the entity layer: + * - A layer to hold the everything + * + * Note that the transformer and object renderer have their own Konva nodes, but they are not stored here. + */ konva: { layer: Konva.Layer; }; + + /** + * The transformer for this entity layer. + */ transformer: CanvasEntityTransformer; + + /** + * The renderer for this entity layer. + */ renderer: CanvasEntityRenderer; isFirstRender: boolean = true;