|
| 1 | +# Canvas |
| 2 | + |
| 3 | +The canvas is a fairly complex feature. It uses "native" KonvaJS (i.e. not the Konva react bindings) to render a drawing canvas. |
| 4 | + |
| 5 | +It supports layers, drawing, erasing, undo/redo, exporting, backend filters (i.e. filters that require sending image data to teh backend to process) and frontend filters. |
| 6 | + |
| 7 | +## Broad Strokes of Design |
| 8 | + |
| 9 | +The canvas is internally is a hierarchy of classes (modules). All canvas modules inherit from invokeai/frontend/web/src/features/controlLayers/konva/CanvasModuleBase.ts |
| 10 | + |
| 11 | +### Modules |
| 12 | + |
| 13 | +The top-level module is the CanvasManager: invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts |
| 14 | + |
| 15 | +All canvas modules have: |
| 16 | + |
| 17 | +- A unique id (per instance) |
| 18 | +- A ref to its parent module and the canvas manager (the top-leve Manager refs itself) |
| 19 | +- A repr() method that returns a plain JS object representing the module instance |
| 20 | +- A destroy() method to clean up resources |
| 21 | +- A log() method that auto-injects context for the module instanc) |
| 22 | + |
| 23 | +Modules can do anything, they are simply plain-JS classes to encapsulate some functionality. Some are singletons. Some examples: |
| 24 | + |
| 25 | +- A singleton module that handles tool-specific interactions: invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts |
| 26 | +- Singleton models for each tool e.g. the CanvasBrushToolModule: invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts |
| 27 | +- A singleton module to render the background of the canvas: invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts |
| 28 | +- A strictly logical module that manages various caches of image data: invokeai/frontend/web/src/features/controlLayers/konva/CanvasCacheModule.ts |
| 29 | +- A non-singleton module that handles rendering a brush stroke: invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts |
| 30 | + |
| 31 | +### Layers (Entities) and Adapters modules |
| 32 | + |
| 33 | +Canvas has a number of layer types: |
| 34 | + |
| 35 | +- Raster layers: Traditional raster/pixel layers, much like layers in Photoshop |
| 36 | +- Control layers: Internally a raster layer, but designated to hold control data (e.g. depth maps, segmentation masks, etc.) and have special rendering rules |
| 37 | +- Regional guidance layers: A mask-like layer (i.e. it has arbitrary shapes but they have no color or texture, it's just a mask region) plus conditioning data like prompts or ref images. The conditioning is applied only to the masked regions |
| 38 | +- Inpaint mask layers: Another mask-like layer that indicate regions to inpaint/regenerate |
| 39 | + |
| 40 | +Instances of layers are called "entities" in the codebase. Each entity has a type (one of the above), a number of properties (e.g. visibility, opacity, etc.), objects (e.g. brush strokes, shapes, images) and possibly other data. |
| 41 | + |
| 42 | +Each layer type has a corresponding "adapter" module that handles rendering the layer and its objects, applying filters, etc. The adapter modules are non-singleton modules that are instantiated once per layer entity. |
| 43 | + |
| 44 | +Using the raster layer type as an example, it has a number of sub-modules: |
| 45 | + |
| 46 | +- A top-level module that coordinates everything: invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts |
| 47 | +- An object (e.g. brush strokes, shapes, images) renderer that draws the layer via Konva: invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts |
| 48 | +- A "buffer" object renderer, which renders in-progress objects (e.g. a brush stroke that is being drawn but not yet committed, important for performance): invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts |
| 49 | +- A module that handles previewing and applying backend filters: invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts |
| 50 | +- A module that handles selecting objects from the pixel data of a layer (aka segmentation tasks): invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts |
| 51 | +- A module that handles transforming the layer (scale, translate, rotate): invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer.ts |
| 52 | + |
| 53 | +## State mgmt |
| 54 | + |
| 55 | +This gets a bit hairy. We have a mix of redux, Konva and nanostores. |
| 56 | + |
| 57 | +At a high level, we use observable/listener patterns to react to state changes and propagate them to where they need to go. |
| 58 | + |
| 59 | +### Redux |
| 60 | + |
| 61 | +Redux is the source of truth for _persistent_ canvas state - layers, their order, etc. |
| 62 | + |
| 63 | +The redux API includes: |
| 64 | + |
| 65 | +- getState(): Get the entire redux state |
| 66 | +- subscribe(listener): Subscribe to state changes, listener is called on _every_ state change, no granularity is provided |
| 67 | +- dispatch(action): Dispatch an action to change state |
| 68 | + |
| 69 | +Redux is not suitable for _transient_ state that changes frequently, e.g. the current brush stroke as the user is drawing it. Syncing every change to redux would be too slow and incur a significant performance penalty that would drop FPS too much. |
| 70 | + |
| 71 | +Canvas modules that have persistent state (e.g. layers, their properties, etc.) use redux to store that state and will subscribe to redux to listen for changes and update themselves as needed. |
| 72 | + |
| 73 | +### Konva |
| 74 | + |
| 75 | +Konva's API is imperative (i.e. you call methods on the Konva nodes to change them) but it renders automatically. |
| 76 | + |
| 77 | +There is no simple way to "subscribe" to changes in Konva nodes. You can listen to certain events (e.g. dragmove, transform, etc.) but there is no generic "node changed" event. |
| 78 | + |
| 79 | +So we almost exclusively push data to Konva, we never "read" from it. |
| 80 | + |
| 81 | +### Nanostores |
| 82 | + |
| 83 | +We use https://github.com/nanostores/nanostores as a lightweight observable state management solution. Nanostores has a plain-JS listener API for subscribing to changes, similar to redux's subscribe(). And it has react bindings so we can use it in react components. |
| 84 | + |
| 85 | +Modules often use nanostores to store their internal state, especially when that state needs to be observed by other modules or react components. |
| 86 | + |
| 87 | +For example, the CanvasToolModule uses a nanostore to hold the current tool (brush, eraser, etc.) and its options (brush size, color, etc.). React components can subscribe to that store to update their UI when the tool or its options change. |
| 88 | + |
| 89 | +So this provides a simple two-way binding between canvas modules and react components. |
| 90 | + |
| 91 | +### State -> Canvas |
| 92 | + |
| 93 | +Data may flow from redux state to Canvas. For example, on canvas init we render all layers and their objects from redux state in Konva: |
| 94 | + |
| 95 | +- Create the layer's entity adapter and all sub-modules |
| 96 | +- Iterate over the layer's objects and create a module instance for each object (e.g. brush stroke, shape, image) |
| 97 | +- Each object module creates the necessary Konva nodes to represent itself and adds them to the layer |
| 98 | + |
| 99 | +The entity adapter subscribes to redux to listen for state changes and pass on the updated state to its sub-modules so they can do whatever they need to do w/ the updated state. |
| 100 | + |
| 101 | +Besides the initial render, we might have to update the Konva representation of a layer when: |
| 102 | + |
| 103 | +- The layer's properties are changed (e.g. visibility, opacity, etc.) |
| 104 | +- The layer's order is changed (e.g. move up/down) |
| 105 | +- User does an undo/redo operation that affects the layer |
| 106 | +- The layer is deleted |
| 107 | + |
| 108 | +### Canvas -> State |
| 109 | + |
| 110 | +When the user interacts w/ the canvas (e.g. draws a brush stroke, erases, moves an object, etc.), we create/update/delete objects in Konva. When the user finishes the interaction (e.g. finishes drawing a brush stroke), we serialize the object to a plain JS object and dispatch a redux action to add the object in redux state. |
| 111 | + |
| 112 | +Using drawing a line on a raster layer as an example, the flow is: |
| 113 | + |
| 114 | +- User initiates a brush stroke and draws |
| 115 | +- We create a brush line object module instance in the layer's buffer renderer |
| 116 | +- The brush line object is given a unique ID |
| 117 | +- The brush line mod creates a Konva.Line node to represent the stroke |
| 118 | +- The brush line mod tracks the stroke as the user draws, updating the Konva.Line node as needed, all in the buffer renderer |
| 119 | +- When the user finishes the stroke, the brush line module transfers control of itself from the layer's buffer renderer to its main renderer |
| 120 | +- As the line is marked complete, the line data is serialized to a plain JS object (i.e. array of points and color) and we dispatch a redux action to add the line object to the layer entity in redux state |
| 121 | + |
| 122 | +Besides drawing tasks, we have similar flows for: |
| 123 | + |
| 124 | +- Transforming a layer (scale, translate, rotate) |
| 125 | +- Filtering a layer |
| 126 | +- Selecting objects from a layer (segmentation tasks) |
| 127 | + |
| 128 | +## Erasing is hard |
| 129 | + |
| 130 | +HTML Canvas has a limited set of compositing modes. These apply globally to the whole canvas element. There is no "local" compositing mode that applies only to a specific shape or object. There is no concept of layers. |
| 131 | + |
| 132 | +So to implement erasing (and opacity!), we have to get creative. Konva handles much of this for us. Each layer is represented internally by a Konva.Layer, which in turn is drawn to its own HTML Canvas element. |
| 133 | + |
| 134 | +Erasing is accomplished by using a globalCompositeOperation of "destination-out" on the brush stroke that is doing the erasing. The brush stroke "cuts a hole" in the layer it is drawn on. |
| 135 | + |
| 136 | +There is a complication. The UX for erasing a layer should be: |
| 137 | + |
| 138 | +- User has a layer, let's say it has an image on it |
| 139 | +- The layer's size is exactly the size of the image |
| 140 | +- User erases the right-hand half of the image |
| 141 | +- The layer's size shrinks to fit the remaining content, i.e. the left half of the image |
| 142 | +- If the user transforms the layer (scale, translate, rotate), the transformations apply only to the remaining content |
| 143 | + |
| 144 | +But the "destination-out" compositing mode only makes the erased pixels transparent. It does not actually remove them from the layer. The layer's bounding box includes the eraser strokes - even though they are transparent. The eraser strokes can actually _enlarge_ the layer's bounding box if the user erases outside the original bounds of the layer. |
| 145 | + |
| 146 | +So, we need a way to calculate the _visual_ bounds of the layer, i.e. the bounding box of all non-transparent pixels. We do this by rendering the layer to an offscreen canvas and reading back the pixel data to calculate the bounds. This process is costly, and we offload some of the work to a web worker to avoid blocking the main thread. Nevertheless, just getting that pixel data is expensive, scaling to the size of the layer. |
| 147 | + |
| 148 | +The usage of the buffer renderer module helps a lot here, as we only need to recalc the bounds when the user finishes a drawing action, not while they are drawing it. |
| 149 | + |
| 150 | +You'll see the relevant code for this in the transformer module. It encapsulates the bounds calculation logic and exposes an observable that holds the last-known visual bounds of the layer. |
| 151 | + |
| 152 | +The worker entrypoint is here invokeai/frontend/web/src/features/controlLayers/konva/CanvasWorkerModule.ts |
| 153 | + |
| 154 | +## Rasterizing layers |
| 155 | + |
| 156 | +Layers consist of a mix of vector and pixel data. For example, a brush stroke is a vector (i.e. array of points) and an image is pixel data. |
| 157 | + |
| 158 | +Ideally we could go straight from user input to pixel data, but this is not feasible for performance reasons. We'd need to write the images to an offscreen canvas, read back the pixel data, send it to the backend, get back the processed pixel data, write it to an offscreen canvas, then read back the pixel data again to update the layer. This would be too slow and block the main thread too much. |
| 159 | + |
| 160 | +So we use a hybrid approach. We keep the vector data in memory and render it to pixel data only when needed, e.g. when the user applies a backend filter or does a transformation on the canvas. |
| 161 | + |
| 162 | +This is unfortunately complicated but we couldn't figure out a more performance way to handle this. |
| 163 | + |
| 164 | +## Compositing layers to prepare for generation |
| 165 | + |
| 166 | +The canvas is a means to an end: provide strong user control and agency for image generation. |
| 167 | + |
| 168 | +When generating an image, the raster layers must be composited toegher into a single image that is sent to the backend. All inpaint masks are similarly composited together into a single mask image. Regional guidance and control layers are not composited together, they are sent as individual images. |
| 169 | + |
| 170 | +This is handled in invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts |
| 171 | + |
| 172 | +For each compositing task, the compositor creates a unique hash of the layer's state (e.g. objects, properties, etc.) and uses that to cache the resulting composited image's name (which ref a unique ref to the image file stored on disk). This avoids re-compositing layers that haven't changed since the last generation. |
| 173 | + |
| 174 | +## The generation bounding box |
| 175 | + |
| 176 | +Image generation models can only generate images up to certain sizes without causing VRAM OOMs. So we need to give the user a way to specify the size of the generation area. This is done via the "generation bounding box" tool, which is a rectangle that the user can resize and move around the canvas. |
| 177 | + |
| 178 | +Here's the module for it invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBboxToolModule.ts |
| 179 | + |
| 180 | +Models all have width/height constraints - they must be multiples of a certain number (typically 8, 16 or 32). This is related to the internal "latents" representatino of images in diffusion models. So the generation bbox must be constrained to these multiples. |
| 181 | + |
| 182 | +## Staging generations |
| 183 | + |
| 184 | +The typical use pattern for generating images on canvas is to generate a number of variations and pick one or more to keep. This is supported via the "staging area", which is a horizontal strip of image thumbnails below the canvas. These staged images are rendered via React, not Konva. |
| 185 | + |
| 186 | +Once canvas generation starts, much of the canvas is locked down until the user finalizes the staging area, either by accepting a single image, adding one or more images as new layers, or discarding all staged images. |
| 187 | + |
| 188 | +The currently-selected staged image is previewed on the canvas and rendered via invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts |
| 189 | + |
| 190 | +When the user accepts a staged image, it is added as a new raster layer (there are other options for adding as control, saving directly to gallery, etc). |
| 191 | + |
| 192 | +This subsystem tracks generated images by watching the queue of generation tasks. The relevant code for queue tracking is in invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts |
| 193 | + |
| 194 | +## Future enhancements |
| 195 | + |
| 196 | +### Perf: Reduce the number of canvas elements |
| 197 | + |
| 198 | +Each layer has a Konva.Layer which has its own canvas element. Once you get too many of these, the browser starts to struggle. |
| 199 | + |
| 200 | +One idea to improve this would be to have a 3-layer system: |
| 201 | + |
| 202 | +- The active layer is its own Konva.Layer |
| 203 | +- All layers behind it are flattened into a single Konva.Layer |
| 204 | +- All layers in front of it are flattened into a single Konva.Layer |
| 205 | + |
| 206 | +When the user switches the active layer, we re-flatten the layers as needed. This would reduce the number of canvas elements to 3 regardless of how many layers there are. This would greatly improve performance, especially on lower-end devices. |
| 207 | + |
| 208 | +### Perf: Konva in a web worker |
| 209 | + |
| 210 | +All of the heavy konva rendering could be offloaded to a web worker. This would free up the main thread for user interactions and UI updates. The main thread would send user input and state changes to the worker, and the worker would send back rendered images to display. |
| 211 | + |
| 212 | +There used to be a hacky example of this on the Konva docs but I can't find it as of this writing. It requires proxying mouse and keyboard events to the worker, but wasn't too complicated. This could be a _huge_ perf win. |
| 213 | + |
| 214 | +### Abstract state bindings |
| 215 | + |
| 216 | +Currently the state bindings (redux, nanostores) are all over the place. There is a singleton module that handles much of the redux binding, but it's still a bit messy: invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts |
| 217 | + |
| 218 | +Many modules still directly subscribe to redux with their own selectors. |
| 219 | + |
| 220 | +Ideally we could have a more abstracted state binding system that could handle multiple backends (e.g. redux, nanostores, etc.) in a more uniform way. This would make it easier to manage state and reduce boilerplate code. |
| 221 | + |
| 222 | +### Do not lock down canvas as much during staging |
| 223 | + |
| 224 | +Currently, once the user starts generating images, much of the canvas is locked down until the user finalizes the staging area. This can be frustrating if the user wants to make small adjustments to layers or settings while previewing staged images, but it prevents footguns. |
| 225 | + |
| 226 | +For example, if the user changes the generation bbox size while staging, then queues up more generations, the output images may not match the bbox size, leading to confusion. |
| 227 | + |
| 228 | +It's more locked-down than it needs to be. Theoretically, most of the canvas could be interactive while staging. Just needs some careful through to not be too confusing. |
0 commit comments