Compare commits

..

542 Commits

Author SHA1 Message Date
psychedelicious
5db7b48cd8 chore(ui): lint 2024-08-28 21:38:57 +10:00
psychedelicious
ea014c66ac chore: release v4.2.9.dev6 2024-08-28 21:33:12 +10:00
psychedelicious
25918c28aa feat(ui): migrate add node popover to cmdk
Put this together as a way to figure out the library before moving on to the full app cmdk. Works great.
2024-08-28 21:32:10 +10:00
psychedelicious
0c60469401 fix(ui): schema parsing now that node_pack is guaranteed to be present 2024-08-28 21:31:15 +10:00
psychedelicious
f1aa50f447 chore(ui): typegen 2024-08-28 21:06:45 +10:00
psychedelicious
a413b261f0 fix(app): node_pack not added to openapi schema correctly 2024-08-28 21:06:33 +10:00
psychedelicious
4a1a6639f6 fix(ui): unnecessary z-index on invoke button 2024-08-28 16:51:44 +10:00
psychedelicious
201c370ca1 feat(ui): split settings modal 2024-08-28 16:51:38 +10:00
psychedelicious
d070c7c726 perf(ui): disable useInert on modals
This hook forcibly updates _all_ portals with `data-hidden=true` when the modal opens - then reverts it when the modal closes. It's intended to help screen readers. Unfortunately, this absolutely tanks performance because we have many portals. React needs to do alot of layout calculations (not re-renders).

IMO this behaviour is a bug in chakra. The modals which generated the portals are hidden by default, so this data attr should really be set by default. Dunno why it isn't.
2024-08-28 16:49:19 +10:00
psychedelicious
e38e20a992 feat(ui): fix queue item count badge positioning
Previously this badge, floating over the queue menu button next to the invoke button, was rendered within the existing layout. When I initially positioned it, the app layout interfered - it would extend into an area reserved for a flex gap, which cut off the badge.

As a (bad) workaround, I had shifted the whole app down a few pixels to make room for it. What I should have done is what I've done in this commit - render the badge in a portal to take it out of the layout so we don't need that extra vertical padding.

Sleekified some styling a bit too.
2024-08-28 16:37:50 +10:00
psychedelicious
39a94ec70e fix(ui): transparency effect not updating 2024-08-28 16:08:12 +10:00
psychedelicious
c7bfae2d1e feat(ui): tidy canvas toolbar buttons 2024-08-28 16:04:37 +10:00
psychedelicious
e7944c427d feat(ui): revised viewer toggle @joshistoast 2024-08-28 16:04:25 +10:00
psychedelicious
48ed4e120d fix(ui): opacity reset value incorrect 2024-08-28 16:03:48 +10:00
psychedelicious
a5b038a1b1 revert(ui): roll back flip, doesn't work with rotate yet 2024-08-28 12:03:38 +10:00
psychedelicious
dc752c98b0 fix(ui): disable opacity slider fully when no valid entity selected 2024-08-28 09:00:55 +10:00
psychedelicious
85a47cc6fe fix(ui): layer preview image sometimes not rendering
The canvas size was dynamic based on the container div's size. When the div was hidden (e.g. when selecting another tab), the container's effective size is 0. This resulted in the preview image canvas being drawn at a scale of 0.

Fixed by using an absolute size for the canvas container.
2024-08-28 08:59:38 +10:00
psychedelicious
6450f42cfa feat(ui): tweak regional prompt box styles 2024-08-28 08:44:04 +10:00
psychedelicious
3876f71ff4 feat(ui): tweak enabled/locked toggle styles 2024-08-28 08:25:37 +10:00
psychedelicious
cf819e8eab feat(ui): tweak filter styling 2024-08-28 08:07:36 +10:00
psychedelicious
2217fb8485 feat(ui): add flip & reset to transform 2024-08-28 07:57:15 +10:00
psychedelicious
43652e830a tidy(ui): use helper to sync scaled bbox size on model change 2024-08-28 07:11:05 +10:00
psychedelicious
a3417bf81d fix(ui): randomize seed toggle linked to prompt concat 2024-08-28 07:05:14 +10:00
psychedelicious
06637161e3 chore: release v4.2.9.dev5 2024-08-27 19:57:34 +10:00
psychedelicious
c4f4b16a36 chore(ui): lint 2024-08-27 19:57:02 +10:00
psychedelicious
3001718f9f feat(ui): generalize mask fill, add to action bar 2024-08-27 19:56:57 +10:00
psychedelicious
ac16fa65a3 feat(ui): implement interaction locking on layers 2024-08-27 19:27:38 +10:00
psychedelicious
bc0b5335ff feat(ui): iterate on layer actions
- Add lock toggle
- Tweak lock and enabled styles
- Update entity list action bar w/ delete & delete all
- Move add layer menu to action bar
- Adjust opacity slider style
2024-08-27 19:08:57 +10:00
psychedelicious
e91c7c5a30 feat(ui): collapsible entity groups 2024-08-27 17:59:21 +10:00
psychedelicious
74791cc490 tidy(ui): rename some classes to be consistent 2024-08-27 17:27:38 +10:00
psychedelicious
68409c6a0f feat(ui): tuned canvas undo/redo
- Throttle pushing to history for actions of the same type, starting with 1000ms throttle.
- History has a limit of 64 items, same as workflow editor
- Add clear history button
- Fix an issue where entity transformers would reset the entity state when the entity is fully transparent, resetting the redo stack. This could happen when you undo to the starting state of a layer
2024-08-27 17:10:01 +10:00
psychedelicious
85613b220c tidy(ui): move all undoable reducers back to canvas slice 2024-08-27 16:25:42 +10:00
psychedelicious
80085ad854 fix(ui): dnd image count 2024-08-27 16:16:11 +10:00
psychedelicious
b6bfa65104 fix(ui): canvas entity opacity scale 2024-08-27 16:16:02 +10:00
psychedelicious
0bfa033089 perf(ui): optimize all selectors 2
Mostly selector optimization. Still a few places to tidy up but I'll get to that later.
2024-08-27 16:04:05 +10:00
psychedelicious
6f0974b5bc perf(ui): optimize all selectors 1
I learned that the inline selector syntax recreates the selector function on every render:

```ts
const val = useAppSelector((s) => s.slice.val)
```

Not good! Better is to create a selector outside the function and use it. Doing that for all selectors now, most of the way through now. Feels snappier.
2024-08-27 13:19:14 +10:00
psychedelicious
c3b53fc4f6 feat(ui): rough out undo/redo on canvas 2024-08-27 12:05:45 +10:00
psychedelicious
8f59a32d81 chore: release v4.2.9.dev4
Canvas dev build.
2024-08-27 11:12:28 +10:00
psychedelicious
3b4f20f433 fix(ui): handle error from internal konva method
We are dipping into konva's private API for preview images and it appears to be unsafe (got an error once). Wrapped in a try/catch.
2024-08-27 11:02:18 +10:00
psychedelicious
73da6e9628 feat(ui): split out loras state from canvas rendering state 2024-08-27 11:02:18 +10:00
psychedelicious
2fc482141d feat(ui): split out session state from canvas rendering state 2024-08-27 11:02:18 +10:00
psychedelicious
a41ec5f3fc feat(ui): split out settings state from canvas rendering state 2024-08-27 11:02:18 +10:00
psychedelicious
c906225d03 feat(ui): split out tool state from canvas rendering state 2024-08-27 11:02:18 +10:00
psychedelicious
2985ea3716 feat(ui): split out params/compositing state from canvas rendering state
First step to restoring undo/redo - the undoable state must be in its own slice. So params and settings must be isolated.
2024-08-27 11:02:18 +10:00
psychedelicious
4f151c6c6f feat(ui): add CanvasModuleBase class to standardize canvas APIs
I did this ages ago but undid it for some reason, not sure why. Caught a few issues related to subscriptions.
2024-08-27 11:02:04 +10:00
psychedelicious
c4f5252c1a feat(ui): move selected tool and tool buffer out of redux
This ephemeral state can live in the canvas classes.
2024-08-27 11:02:04 +10:00
psychedelicious
77c13f2cf3 feat(ui): move ephemeral state into canvas classes
Things like `$lastCursorPos` are now created within the canvas drawing classes. Consumers in react access them via `useCanvasManager`.

For example:
```tsx
const canvasManager = useCanvasManager();
const lastCursorPos = useStore(canvasManager.stateApi.$lastCursorPos);
```
2024-08-27 11:02:04 +10:00
psychedelicious
3270d36fca feat(ui): normalize all actions to accept an entityIdentifier
Previously, canvas actions specific to an entity type only needed the id of that entity type. This allowed you to pass in the id of an entity of the wrong type.

All actions for a specific entity now take a full entity identifier, and the entity identifier type can be narrowed.

`selectEntity` and `selectEntityOrThrow` now need a full entity identifier, and narrow their return values to a specific entity type _if_ the entity identifier is narrowed.

The types for canvas entities are updated with optional type parameters for this purpose.

All reducers, actions and components have been updated.
2024-08-27 11:02:04 +10:00
psychedelicious
b6b30ff01f feat(ui): move events into modules who care about them 2024-08-27 11:02:04 +10:00
psychedelicious
aa9bfdff35 fix(ui): color picker resets brush opacity 2024-08-27 11:02:04 +10:00
psychedelicious
80308cc3b8 fix(ui): scaled bbox loses sync 2024-08-27 11:02:04 +10:00
psychedelicious
f6db73bf1f feat(ui): add context menu to entity list 2024-08-27 11:02:04 +10:00
psychedelicious
ef9f61a39f chore(ui): bump @invoke-ai/ui-library 2024-08-27 11:02:04 +10:00
psychedelicious
a1a0881133 fix(ui): missing vae precision in graph builders 2024-08-27 11:02:04 +10:00
psychedelicious
9956919ab6 chore: release v4.2.9.dev3
Instead of using dates, just going to increment.
2024-08-27 11:02:04 +10:00
psychedelicious
abc07f57d6 feat(ui): use new Result utils for enqueueing 2024-08-27 11:02:04 +10:00
psychedelicious
1a1cae79f1 fix(ui): graph building issue w/ controlnet 2024-08-27 11:02:04 +10:00
psychedelicious
bcfafe7b06 feat(ui): add Result type & helpers
Wrappers to capture errors and turn into results:
- `withResult` wraps a sync function
- `withResultAsync` wraps an async function

Comments, tests.
2024-08-27 11:02:04 +10:00
psychedelicious
34e8ced592 chore: release v4.2.9.dev20240824 2024-08-27 11:02:04 +10:00
psychedelicious
1fdada65b6 fix(ui): lint & fix issues with adding regional ip adapters 2024-08-27 11:02:04 +10:00
psychedelicious
433f3e1971 feat(ui): add knipignore tag
I'm not ready to delete some things but still want to build the app.
2024-08-27 11:02:04 +10:00
psychedelicious
a60e23f825 feat(ui): duplicate entity 2024-08-27 11:02:04 +10:00
psychedelicious
f69de3148e feat(ui): autocomplete on getPrefixeId 2024-08-27 11:02:04 +10:00
psychedelicious
cbcd36ef54 feat(ui): paste canvas gens back on source in generate mode 2024-08-27 11:02:04 +10:00
psychedelicious
aa76134340 chore(ui): typegen 2024-08-27 11:02:04 +10:00
psychedelicious
55758acae8 feat(nodes): CanvasV2MaskAndCropInvocation can paste generated image back on source
This is needed for `Generate` mode.
2024-08-27 11:02:04 +10:00
psychedelicious
196e43b5e5 fix(ui): extraneous entity preview updates 2024-08-27 11:02:04 +10:00
psychedelicious
38b9828441 fix(ui): newly-added entities are selected 2024-08-27 11:02:04 +10:00
psychedelicious
0048a7077e feat(ui): add crosshair to color picker 2024-08-27 11:02:04 +10:00
psychedelicious
527a39a3ad fix(ui): color picker ignores alpha 2024-08-27 11:02:04 +10:00
psychedelicious
30ce4c55c7 fix(ui): calculate renderable entities correctly in tool module 2024-08-27 11:02:04 +10:00
psychedelicious
ca082d4288 feat(ui): better color picker 2024-08-27 11:02:04 +10:00
psychedelicious
5e59a4f43a feat(ui): colored mask preview image 2024-08-27 11:02:04 +10:00
psychedelicious
9f86605049 fix(ui): new rectangles don't trigger rerender 2024-08-27 11:02:04 +10:00
psychedelicious
79058a7894 chore: bump version v4.2.9.dev20240823 2024-08-27 11:02:04 +10:00
psychedelicious
bb3ad8c2f1 feat(ui): disable most interaction while filtering 2024-08-27 11:02:04 +10:00
psychedelicious
799688514b fix(ui): filter preview offset 2024-08-27 11:02:04 +10:00
psychedelicious
b7344b0df2 feat(ui): tweak layout of staging area toolbar 2024-08-27 11:02:04 +10:00
psychedelicious
7e382c5f3f chore(ui): typegen 2024-08-27 11:02:04 +10:00
psychedelicious
9cf357e184 tidy(app): clean up app changes for canvas v2 2024-08-27 11:01:53 +10:00
psychedelicious
95b6c773d4 feat(ui): use singleton for clear q confirm dialog 2024-08-27 11:01:53 +10:00
psychedelicious
89d8c5ba00 fix(ui): rip out broken recall logic, NO TS ERRORS 2024-08-27 11:01:53 +10:00
psychedelicious
59580cf6ed chore(ui): lint 2024-08-27 11:01:53 +10:00
psychedelicious
2b0c084f5b fix(ui): staging area interaction scopes 2024-08-27 11:01:53 +10:00
psychedelicious
4d896073ff fix(ui): staging area actions 2024-08-27 11:01:53 +10:00
psychedelicious
9f69503a80 tidy(ui): more cleanup 2024-08-27 11:01:53 +10:00
psychedelicious
0311e852a0 fix(ui): upscale tab graph 2024-08-27 11:01:53 +10:00
psychedelicious
7003a3d546 fix(ui): sdxl graph builder 2024-08-27 11:01:53 +10:00
psychedelicious
dc73072e27 fix(ui): select next entity in the list when deleting 2024-08-27 11:01:53 +10:00
psychedelicious
e549c44ad7 feat(ui): fix delete layer hotkey 2024-08-27 11:01:52 +10:00
psychedelicious
45a4231cbe tidy(ui): "eye dropper" -> "color picker" 2024-08-27 11:01:52 +10:00
psychedelicious
81f046ebac tidy(ui): regional guidance buttons 2024-08-27 11:01:52 +10:00
psychedelicious
6ef6c593c4 feat(ui): update entity list menu 2024-08-27 11:01:52 +10:00
psychedelicious
5b53eefef7 feat(ui): add log debug button 2024-08-27 11:01:52 +10:00
psychedelicious
9a9919c0af chore(ui): lint 2024-08-27 11:01:52 +10:00
psychedelicious
10661b33d4 chore(ui): prettier 2024-08-27 11:01:52 +10:00
psychedelicious
52193d604d chore(ui): eslint 2024-08-27 11:01:52 +10:00
psychedelicious
2568441e6a tidy(ui): remove unused stuff 4 2024-08-27 11:01:52 +10:00
psychedelicious
1a14860b3b tidy(ui): remove unused stuff 3 2024-08-27 11:01:52 +10:00
psychedelicious
9ff7647ec5 tidy(ui): remove unused pkg @chakra-ui/react-use-size 2024-08-27 11:01:52 +10:00
psychedelicious
b49106e8fe feat(ui): revise graph building for control layers, fix issues w/ invocation complete events 2024-08-27 11:01:52 +10:00
psychedelicious
906d0902a3 feat(ui): use unique id for metadata in Graph class 2024-08-27 11:01:52 +10:00
psychedelicious
fbde6f5a7f tidy(ui): remove unused stuff 2 2024-08-27 11:01:52 +10:00
psychedelicious
b388268987 tidy(ui): remove unused stuff 2024-08-27 11:01:52 +10:00
psychedelicious
3b4164bd62 tidy(ui): reduce use of parseify util 2024-08-27 11:01:52 +10:00
psychedelicious
b7fc6fe573 feat(ui): refine canvas entity list items & menus 2024-08-27 11:01:52 +10:00
psychedelicious
2954a19d27 feat(ui): canvas layer preview, revised reactivity for adapters 2024-08-27 11:01:52 +10:00
psychedelicious
aa45ce7fbd feat(ui): add SyncableMap
Can be used with useSyncExternal store to make a `Map` reactive.
2024-08-27 11:01:52 +10:00
psychedelicious
77e5078e4a tidy(ui): removed unused transform methods from canvasmanager 2024-08-27 11:01:52 +10:00
psychedelicious
603cc7bf2e feat(ui): transform tool ux 2024-08-27 11:01:52 +10:00
psychedelicious
cd517a102d feat(ui): rough out canvas mode 2024-08-27 11:01:52 +10:00
psychedelicious
9a442918b5 feat(ui): add canvas autosave checkbox 2024-08-27 11:01:52 +10:00
psychedelicious
f9c03d85a5 fix(ui): memory leak when getting image DTO
must unsubscribe!
2024-08-27 11:01:52 +10:00
psychedelicious
10d07c71c4 feat(ui): rework settings menu 2024-08-27 11:01:52 +10:00
psychedelicious
cd05a78219 feat(ui): no entities fallback buttons 2024-08-27 11:01:52 +10:00
psychedelicious
f8ee572abc perf(ui): optimize gallery image delete button rendering 2024-08-27 11:01:52 +10:00
psychedelicious
d918654509 feat(ui): remove "solid" background option 2024-08-27 11:01:52 +10:00
psychedelicious
582e30c542 tidy(ui): organise files and classes 2024-08-27 11:01:52 +10:00
psychedelicious
34a6555301 tidy(ui): abstract compositing logic to module 2024-08-27 11:01:52 +10:00
psychedelicious
fff860090b fix(ui): fix canvas cache property access 2024-08-27 11:01:52 +10:00
psychedelicious
f4971197c1 tidy(ui): clean up CanvasFilter class 2024-08-27 11:01:52 +10:00
psychedelicious
621d5e0462 tidy(ui): clean up a few bits and bobs 2024-08-27 11:01:52 +10:00
psychedelicious
0b68a69a6c tidy(ui): abstract canvas rendering logic to module 2024-08-27 11:01:52 +10:00
psychedelicious
9a599ce595 tidy(ui): abstract caching logic to module 2024-08-27 11:01:52 +10:00
psychedelicious
1467ba276f tidy(ui): abstract worker logic to module 2024-08-27 11:01:52 +10:00
psychedelicious
708facf707 tidy(ui): abstract stage logic into module 2024-08-27 11:01:52 +10:00
psychedelicious
9c6c6adb1f feat(ui): add entity group hiding 2024-08-27 11:01:52 +10:00
psychedelicious
c335b8581c feat(ui): move all caching out of redux
While we lose the benefit of the caches persisting across reloads, this is a much simpler way to handle things. If we need a persistent cache, we can explore it in the future.
2024-08-27 11:01:52 +10:00
psychedelicious
f1348e45bd feat(ui): revised rasterization caching
- use `stable-hash` to generate stable, non-crypto hashes for cache entries, instead of using deep object comparisons
- use an object to store image name caches
2024-08-27 11:01:52 +10:00
psychedelicious
ce6cf9b079 feat(ui): revise filter implementation 2024-08-27 11:01:52 +10:00
psychedelicious
13ec80736a fix(ui): add button to delete inpaint mask 2024-08-27 11:01:52 +10:00
psychedelicious
c9690a4b21 feat(ui): add contexts/hooks to access entity adapters directly 2024-08-27 11:01:52 +10:00
psychedelicious
489e875a6e feat(ui): add CanvasManagerProviderGate
This context waits to render its children its until the canvas manager is available. Then its children have access to the manager directly via hook.
2024-08-27 11:01:52 +10:00
psychedelicious
8651396048 feat(ui) do not set $canvasManager until ready 2024-08-27 11:01:52 +10:00
psychedelicious
2bab5a6179 fix(ui): inpaint mask naming 2024-08-27 11:01:52 +10:00
psychedelicious
006f06b615 feat(ui): efficient canvas compositing
Also solves issue of exporting layers at different opacities than what is visible
2024-08-27 11:01:52 +10:00
psychedelicious
d603923d1b feat(ui): allow multiple inpaint masks
This is easier than making it a nullable singleton
2024-08-27 11:01:52 +10:00
psychedelicious
86878e855b fix(ui): missing rasterization cache invalidations 2024-08-27 11:01:52 +10:00
psychedelicious
35de60a8fa feat(ui): iterate on filter UI, flow 2024-08-27 11:01:52 +10:00
psychedelicious
2c444a1941 fix(ui): rehydration data loss 2024-08-27 11:01:52 +10:00
psychedelicious
3dfef01889 feat(ui): sort log namespaces 2024-08-27 11:01:52 +10:00
psychedelicious
a845a2daa5 fix(ui): do not merge arrays by index during rehydration 2024-08-27 11:01:52 +10:00
psychedelicious
df41f4fbce fix(ui): clone parsed data during state rehydration
Without this, the objects and arrays in `parsed` could be mutated, and the log statment would show the mutated data.
2024-08-27 11:01:52 +10:00
psychedelicious
76482da6f5 fix(ui): fix logger filter
was accidetnally replacing the filter instead of appending to it.
2024-08-27 11:01:52 +10:00
psychedelicious
8205abbbbf fix(ui): race condition queue status
Sequence of events causing the race condition:
- Enqueue batch
- Invalidate `SessionQueueStatus` tag
- Request updated queue status via HTTP - batch still processing at this point
- Batch completes
- Event emitted saying so
- Optimistically update the queue status cache, it is correct
- HTTP request makes it back and overwrites the optimistic update, indicating the batch is still in progress

FIxed by not invalidating the cache.
2024-08-27 11:01:52 +10:00
psychedelicious
926873de26 fix(ui): handle opacity for masks 2024-08-27 11:01:52 +10:00
psychedelicious
00cb1903ba feat(ui): default background to checkerboard 2024-08-27 11:01:52 +10:00
psychedelicious
58ba38b9c7 feat(ui): clean up logging namespaces, allow skipping namespaces 2024-08-27 11:01:52 +10:00
psychedelicious
2f6a5617f9 chore(ui): bump ui library 2024-08-27 11:01:52 +10:00
psychedelicious
e0d84743be fix(ui): do not allow drawing if layer disabled 2024-08-27 11:01:52 +10:00
psychedelicious
ee7c62acc4 fix(ui): stale state causing race conditions & extraneous renders 2024-08-27 11:01:52 +10:00
psychedelicious
daf3e58bd9 fix(ui): do not clear buffer when rendering "real" objects 2024-08-27 11:01:52 +10:00
psychedelicious
c5b9209057 tidy(ui): remove "filter" from CanvasImageState 2024-08-27 11:01:52 +10:00
psychedelicious
2a4d6d98e2 feat(ui): better editable title 2024-08-27 11:01:52 +10:00
psychedelicious
cfdf59d906 fix(ui): stroke eraserline 2024-08-27 11:01:52 +10:00
psychedelicious
f91ce1a47c feat(ui): restore transparency effect for control layers 2024-08-27 11:01:52 +10:00
psychedelicious
4af2888168 feat(ui): use text cursor for entity title 2024-08-27 11:01:52 +10:00
psychedelicious
8471c6fe86 tidy(ui): remove extraneous logging in CanvasStateApi 2024-08-27 11:01:52 +10:00
psychedelicious
fe65a5a2db feat(ui): better buffer commit logic 2024-08-27 11:01:52 +10:00
psychedelicious
0df26e967c feat(ui): render buffer separately from "real" objects 2024-08-27 11:01:52 +10:00
psychedelicious
d4822b305e fix(ui): pixelRect should always be integer 2024-08-27 11:01:52 +10:00
psychedelicious
8df5447563 fix(ui): only update stage attrs when stage itself is dragged 2024-08-27 11:01:52 +10:00
psychedelicious
7b5a43df9b feat(ui): add line simplification
This fixes some awkward issues where line segments stack up.
2024-08-27 11:01:52 +10:00
psychedelicious
61ef630175 fix(ui): various things listening when they need not listen 2024-08-27 11:01:52 +10:00
psychedelicious
4eda2ef555 feat(ui): layer opacity via caching 2024-08-27 11:01:52 +10:00
psychedelicious
57f4489520 feat(ui): reset view fits all visible objects 2024-08-27 11:01:52 +10:00
psychedelicious
fb6cf9e3da fix(ui): rerenders when changing canvas scale 2024-08-27 11:01:52 +10:00
psychedelicious
f776326cff fix(ui): do not render rasterized layer unless renderObjects=true 2024-08-27 11:01:52 +10:00
psychedelicious
5be32d5733 feat(ui): revise app layout strategy, add interaction scopes for hotkeys 2024-08-27 11:01:52 +10:00
psychedelicious
1f73435241 feat(ui): tweak mask patterns 2024-08-27 11:01:52 +10:00
psychedelicious
3251a00631 fix(ui): dynamic prompts recalcs when presets are loaded 2024-08-27 11:01:52 +10:00
psychedelicious
49c4ad1dd7 fix(ui): use style preset prompts correctly 2024-08-27 11:01:52 +10:00
psychedelicious
5857e95c4a fix(ui): discard selected staging image not all other images 2024-08-27 11:01:52 +10:00
psychedelicious
85be2532c6 fix(ui): respect image size in staging preview 2024-08-27 11:01:52 +10:00
psychedelicious
8b81a00def tidy(ui): cleanup after events change 2024-08-27 11:01:52 +10:00
psychedelicious
8544595c27 feat(ui): move socket event handling out of redux
Download events and invocation status events (including progress images) are very frequent. There's no real need for these to pass through redux. Handling them outside redux is a significant performance win - far fewer store subscription calls, far fewer trips through middleware.

All event handling is moved outside middleware. Cleanup of unused actions and listeners to follow.
2024-08-27 11:01:52 +10:00
psychedelicious
a6a5d1470c fix(ui): rebase conflicts 2024-08-27 11:01:39 +10:00
psychedelicious
febcc12ec9 fix(ui): update compositing rect when fill changes 2024-08-27 11:01:39 +10:00
psychedelicious
ab64078b76 feat(ui): add canvas background style 2024-08-27 11:01:39 +10:00
psychedelicious
0ff3459b07 feat(ui): mask layers choose own opacity 2024-08-27 11:01:39 +10:00
psychedelicious
2abd7c9bfe feat(ui): mask fill patterns 2024-08-27 11:01:39 +10:00
psychedelicious
8e5330bdc9 build(ui): add vite types to tsconfig 2024-08-27 11:01:39 +10:00
psychedelicious
1ecec4ea3a fix(ui): do not smooth pixel data when using eyeDropper 2024-08-27 11:01:39 +10:00
psychedelicious
700dbe69f3 tidy(ui): tool components & translations 2024-08-27 11:01:39 +10:00
psychedelicious
af7ba3b7e4 feat(ui): rough out eyedropper tool
It's a bit slow bc we are converting the stage to canvas on every mouse move. Also need to improve the visual but it works.
2024-08-27 11:01:39 +10:00
psychedelicious
1f7144d62e fix(ui): ip adapters work 2024-08-27 11:01:39 +10:00
psychedelicious
4e389e415b feat(ui): rename layers 2024-08-27 11:01:39 +10:00
psychedelicious
2db29fb6ab feat(ui): revise entity menus 2024-08-27 11:01:39 +10:00
psychedelicious
79653fcff5 feat(ui): split control layers from raster layers for UI and internal state, same rendering as raster layers 2024-08-27 11:01:39 +10:00
psychedelicious
9e39180fbc feat(ui): implement cache for image rasterization, rip out some old controladapters code 2024-08-27 11:01:39 +10:00
psychedelicious
dc0f832d8f feat(ui, app): use layer as control (wip) 2024-08-27 11:01:39 +10:00
psychedelicious
0dcd6aa5d9 feat(ui): add contextmenu for canvas entities 2024-08-27 11:01:39 +10:00
psychedelicious
9f4a8f11f8 feat(ui): more better logging & naming 2024-08-27 11:01:39 +10:00
psychedelicious
6f9579d6ec feat(ui): better logging w/ path 2024-08-27 11:01:39 +10:00
psychedelicious
2b2aabb234 feat(ui): always show marks on canvas scale slider 2024-08-27 11:01:39 +10:00
psychedelicious
a79b9633ab fix(ui): do not import button from chakra 2024-08-27 11:01:39 +10:00
psychedelicious
7b628c908b fix(ui): scaled bbox preview 2024-08-27 11:01:39 +10:00
psychedelicious
181703a709 feat(ui): tidy up atoms 2024-08-27 11:01:39 +10:00
psychedelicious
c439e3c204 feat(ui): convert all my pubsubs to atoms
its the same but better
2024-08-27 11:01:39 +10:00
psychedelicious
11e81eb456 feat(ui): add trnalsation 2024-08-27 11:01:39 +10:00
psychedelicious
3e24bf640e fix(ui): give up on thumbnail loading, causes flash during transformer 2024-08-27 11:01:39 +10:00
psychedelicious
a17664fb75 fix(ui): depth anything v2 2024-08-27 11:01:39 +10:00
psychedelicious
5aed23dc91 tidy(ui): remove unused code, comments 2024-08-27 11:01:39 +10:00
psychedelicious
7f389716d0 fix(ui): staging area works 2024-08-27 11:01:39 +10:00
psychedelicious
eca13b674a feat(nodes): temp disable canvas output crop 2024-08-27 11:01:39 +10:00
psychedelicious
7c982a1bdf fix(ui): max scale 1 when reset view 2024-08-27 11:01:39 +10:00
psychedelicious
bdfe6870fd feat(ui): better scale changer component, reset view functionality 2024-08-27 11:01:39 +10:00
psychedelicious
7eebbc0dd9 fix(ui): img2img 2024-08-27 11:01:39 +10:00
psychedelicious
6ae46d7c8b feat(ui): add manual scale controls 2024-08-27 11:01:39 +10:00
psychedelicious
8aae30566e fix(ui): do not await clearBuffer 2024-08-27 11:01:39 +10:00
psychedelicious
4b7c3e221c feat(ui): dnd image into layer 2024-08-27 11:01:39 +10:00
psychedelicious
4015795b7f fix(ui): do not await commitBuffer 2024-08-27 11:01:39 +10:00
psychedelicious
132dd61d8d fix(ui): properly destroy entities in manager cleanup 2024-08-27 11:01:39 +10:00
psychedelicious
6f2b548dd1 tidy(ui): clearer component names for regional guidance 2024-08-27 11:01:39 +10:00
psychedelicious
49dd316f17 tidy(ui): clearer component names for ip adapter 2024-08-27 11:01:39 +10:00
psychedelicious
e0a8bb149d tidy(ui): clearer component names for inpaint mask 2024-08-27 11:01:39 +10:00
psychedelicious
020b6db34b tidy(ui): clearer component names for control adapters 2024-08-27 11:01:39 +10:00
psychedelicious
f6d2f0bf8c feat(ui): simplify canvas list item headers 2024-08-27 11:01:39 +10:00
psychedelicious
1280cce803 fix(ui): ip adapter list item 2024-08-27 11:01:39 +10:00
psychedelicious
82463d12e2 tidy(ui): clean up unused logic 2024-08-27 11:01:39 +10:00
psychedelicious
ba6c1b84e4 feat(ui): clean up state, add mutex for image loading, add thumbnail loading 2024-08-27 11:01:39 +10:00
psychedelicious
92b6d3198a chore(ui): add async-mutex dep 2024-08-27 11:01:39 +10:00
psychedelicious
693ae1af50 feat(ui): txt2img, img2img, inpaint & outpaint working 2024-08-27 11:01:39 +10:00
psychedelicious
3873a3096c feat(ui): no padding on transformer outlines 2024-08-27 11:01:39 +10:00
psychedelicious
4a9f6ab5ef feat(ui): restore object count to layer titles 2024-08-27 11:01:39 +10:00
psychedelicious
2fc29c5125 tidy(ui): "useIsEntitySelected" -> "useEntityIsSelected" 2024-08-27 11:01:39 +10:00
psychedelicious
5fbc876cfd tidy(ui): move transformer statics into class 2024-08-27 11:01:39 +10:00
psychedelicious
89b0673ac9 tidy(ui): massive cleanup
- create a context for entity identifiers, massively simplifying UI for each entity int he list
- consolidate common redux actions
- remove now-unused code
2024-08-27 11:01:39 +10:00
psychedelicious
3179a16189 perf(ui): do not add duplicate points to lines 2024-08-27 11:01:39 +10:00
psychedelicious
3ce216b391 feat(ui): up line tension to 0.3 2024-08-27 11:01:39 +10:00
psychedelicious
8a3a94e21a perf(ui): disable stroke, perfect draw on compositing rect 2024-08-27 11:01:39 +10:00
psychedelicious
f61af188f9 tidy(ui): remove unused code, initial image 2024-08-27 11:01:39 +10:00
psychedelicious
73fc52bfed tidy(ui): remove unused state & actions 2024-08-27 11:01:39 +10:00
psychedelicious
4eeff4eef8 feat(ui): region mask rendering 2024-08-27 11:01:39 +10:00
psychedelicious
cff2c43030 feat(ui): esc cancels drawing buffer
maybe this is not wanted? we'll see
2024-08-27 11:01:39 +10:00
psychedelicious
504b1f2425 fix(ui): render transformer over objects, fix issue w/ inpaint rect color 2024-08-27 11:01:38 +10:00
psychedelicious
eff5b56990 fix(ui): brush preview fill for inpaint/region 2024-08-27 11:01:38 +10:00
psychedelicious
0e673f1a18 fix(ui): no objects rendered until vis toggled 2024-08-27 11:01:38 +10:00
psychedelicious
4fea22aea4 feat(ui): inpaint mask transform 2024-08-27 11:01:38 +10:00
psychedelicious
c2478c9ac3 fix(ui): layer accidental early set isFirstRender=false 2024-08-27 11:01:38 +10:00
psychedelicious
ed8243825e fix(ui): inpaint mask rendering 2024-08-27 11:01:38 +10:00
psychedelicious
c7ac2b5278 feat(ui): wip inpaint mask uses new API 2024-08-27 11:01:38 +10:00
psychedelicious
a783003556 feat(ui): move updatePosition to transformer 2024-08-27 11:01:38 +10:00
psychedelicious
44ba1c6113 feat(ui): move resetScale to transformer 2024-08-27 11:01:38 +10:00
psychedelicious
a02d67fcc6 tidy(ui): more imperative naming 2024-08-27 11:01:38 +10:00
psychedelicious
14c8f7c4f5 tidy(ui): use imperative names for setters in stateapi 2024-08-27 11:01:38 +10:00
psychedelicious
a487ecb50f fix(ui): commit drawing buffer on tool change, fixing bbox not calculating 2024-08-27 11:01:38 +10:00
psychedelicious
fc55862823 fix(ui): sync transformer when requesting bbox calc 2024-08-27 11:01:38 +10:00
psychedelicious
e5400601d6 tidy(ui): rename union CanvasEntity -> CanvasEntityState 2024-08-27 11:01:38 +10:00
psychedelicious
735f9f1483 fix(ui): request rect calc immediately on transform, hiding rect 2024-08-27 11:01:38 +10:00
psychedelicious
d139db0a0f feat(ui): move bbox calculation to transformer 2024-08-27 11:01:38 +10:00
psychedelicious
a35bb450b1 feat(ui): use set for transformer subscriptions 2024-08-27 11:01:38 +10:00
psychedelicious
26c01dfa48 tidy(ui): clean up worker tasks when complete 2024-08-27 11:01:38 +10:00
psychedelicious
2b1839374a tidy(ui): remove unused code in CanvasTool 2024-08-27 11:01:38 +10:00
psychedelicious
59f5f18e1d feat(ui): use pubsub for isTransforming on manager 2024-08-27 11:01:38 +10:00
psychedelicious
e41fcb081c docs(ui): update transformer docstrings 2024-08-27 11:01:38 +10:00
psychedelicious
3032042b35 feat(ui): revised event pubsub, transformer logic split out 2024-08-27 11:01:38 +10:00
psychedelicious
544db61044 feat(ui): add simple pubsub 2024-08-27 11:01:38 +10:00
psychedelicious
67a3aa6dff feat(ui): document & clean up object renderer 2024-08-27 11:01:38 +10:00
psychedelicious
34f4468b20 feat(ui): split out object renderer 2024-08-27 11:01:38 +10:00
psychedelicious
d99ae58001 fix(ui): unable to hold shit while transforming to retain ratio 2024-08-27 11:01:38 +10:00
psychedelicious
d60ec53762 tidy(ui): rename canvas stuff 2024-08-27 11:01:38 +10:00
psychedelicious
0ece9361d5 tidy(ui): consolidate getLoggingContext builders 2024-08-27 11:01:38 +10:00
psychedelicious
69987a2f00 fix(ui): align all tools to 1px grid
- Offset brush tool by 0.5px when width is odd, ensuring each stroke edge is exactly on a pixel boundary
- Round the rect tool also
2024-08-27 11:01:38 +10:00
psychedelicious
7346bfccb9 feat(ui): disable image smoothing on layers 2024-08-27 11:01:38 +10:00
psychedelicious
b705083ce2 fix(ui): round position when rasterizing layer 2024-08-27 11:01:38 +10:00
psychedelicious
4b3c82df6f feat(ui): continue modularizing transform 2024-08-27 11:01:38 +10:00
psychedelicious
46290205d5 feat(ui): fix a few things that didn't unsubscribe correctly, add helper to manage subscriptions 2024-08-27 11:01:38 +10:00
psychedelicious
7b15585b80 feat(ui): merge bbox outline into transformer 2024-08-27 11:01:38 +10:00
psychedelicious
cbfeeb9079 fix(ui): update parent's pos not transformers 2024-08-27 11:01:38 +10:00
psychedelicious
fdac20b43e feat(ui): merge interaction rect into transformer class 2024-08-27 11:01:38 +10:00
psychedelicious
ae2312104e feat(ui): prepare staging area 2024-08-27 11:01:38 +10:00
psychedelicious
74b6674af6 feat(ui): typing for logging context 2024-08-27 11:01:38 +10:00
psychedelicious
81adce3238 feat(ui): remove inheritance of CanvasObject
JS is terrible
2024-08-27 11:01:38 +10:00
psychedelicious
48b7f460a8 feat(ui): split & document transformer logic, iterate on class structures 2024-08-27 11:01:38 +10:00
psychedelicious
38a8232341 feat(ui): rotation snap to nearest 45deg when holding shift 2024-08-27 11:01:38 +10:00
psychedelicious
39a51c4f4c feat(ui): expose subscribe method for nanostores 2024-08-27 11:01:38 +10:00
psychedelicious
220bfeb37d tidy(ui): remove layer scaling reducers 2024-08-27 11:01:38 +10:00
psychedelicious
e766279950 fix(ui): pixel-perfect transforms 2024-08-27 11:01:38 +10:00
psychedelicious
db82406525 fix(ui): layer visibility toggle 2024-08-27 11:01:38 +10:00
psychedelicious
2d44f332d9 fix(nodes): fix canvas mask erode
it wasn't eroding enough and caused incorrect transparency in result images
2024-08-27 11:01:38 +10:00
psychedelicious
ea1526689a fix(ui): do not reset layer on first render 2024-08-27 11:01:38 +10:00
psychedelicious
200338ed72 feat(ui): revised logging and naming setup, fix staging area 2024-08-27 11:01:38 +10:00
psychedelicious
88003a61bd feat(ui): add repr methods to layer and object classes 2024-08-27 11:01:38 +10:00
psychedelicious
b82089b30b feat(ui): use nanoid(10) instead of uuidv4 for canvas
Shorter ids makes it much more readable
2024-08-27 11:01:38 +10:00
psychedelicious
efd780d395 build(ui): add nanoid as explicit dep 2024-08-27 11:01:38 +10:00
psychedelicious
3f90f783de fix(ui): move CanvasImage's konva image to correct object 2024-08-27 11:01:38 +10:00
psychedelicious
4f5b755117 fix(ui): prevent flash when applying transform 2024-08-27 11:01:38 +10:00
psychedelicious
a455f12581 build(ui): add eslint rules for async stuff 2024-08-27 11:01:38 +10:00
psychedelicious
de516db383 feat(ui): trying to fix flicker after transform 2024-08-27 11:01:38 +10:00
psychedelicious
6781575293 feat(ui): transform cleanup 2024-08-27 11:01:38 +10:00
psychedelicious
65b0e40fc8 feat(ui): fix transform when rotated 2024-08-27 11:01:38 +10:00
psychedelicious
19e78a07b7 fix(ui): use pixel bbox when image is in layer 2024-08-27 11:01:38 +10:00
psychedelicious
e3b60dda07 fix(ui): transforming when axes flipped 2024-08-27 11:01:38 +10:00
psychedelicious
a9696d3193 feat(ui): hallelujah (???) 2024-08-27 11:01:38 +10:00
psychedelicious
336d72873f feat(ui): add debug button 2024-08-27 11:01:38 +10:00
psychedelicious
111a380bce fix(ui): transformer padding 2024-08-27 11:01:38 +10:00
psychedelicious
012a8351af feat(ui): wip transform mode 2 2024-08-27 11:01:38 +10:00
psychedelicious
deeb80ea9b feat(ui): wip transform mode 2024-08-27 11:01:38 +10:00
psychedelicious
1e97a917d6 feat(ui): wip transform mode 2024-08-27 11:01:38 +10:00
psychedelicious
a15ba925db fix(ui): dnd to canvas broke 2024-08-27 11:01:38 +10:00
psychedelicious
6e5a968aad fix(ui): conflicts after rebasing 2024-08-27 11:01:38 +10:00
psychedelicious
915edaa02f fix(ui): imageDropped listener 2024-08-27 11:01:38 +10:00
psychedelicious
f6b0fa7c18 wip 2024-08-27 11:01:38 +10:00
psychedelicious
3f1fba0f35 fix(ui): transform tool seems to be working 2024-08-27 11:01:38 +10:00
psychedelicious
d9fa85a4c6 fix(ui): move tool fixes, add transform tool 2024-08-27 11:01:38 +10:00
psychedelicious
022bb8649c feat(ui): move tool now only moves 2024-08-27 11:01:38 +10:00
psychedelicious
673bc33a01 feat(ui): layer bbox calc in worker 2024-08-27 11:01:38 +10:00
psychedelicious
579a64928d feat(ui): tweaked entity & group selection styles 2024-08-27 11:01:38 +10:00
psychedelicious
5316df7d7d feat(ui): canvas entity list headers 2024-08-27 11:01:38 +10:00
psychedelicious
62fe61dc30 tidy(ui): CanvasRegion 2024-08-27 11:01:38 +10:00
psychedelicious
ea819a4a2f tidy(ui): CanvasRect 2024-08-27 11:01:38 +10:00
psychedelicious
868a25dae2 tidy(ui): CanvasLayer 2024-08-27 11:01:38 +10:00
psychedelicious
3a25d00cb6 tidy(ui): CanvasInpaintMask 2024-08-27 11:01:38 +10:00
psychedelicious
6301b74d87 tidy(ui): CanvasInitialImage 2024-08-27 11:01:38 +10:00
psychedelicious
b43b90c299 tidy(ui): CanvasImage 2024-08-27 11:01:38 +10:00
psychedelicious
0a43444ab3 tidy(ui): CanvasEraserLine 2024-08-27 11:01:38 +10:00
psychedelicious
75ea4d2155 tidy(ui): CanvasControlAdapter 2024-08-27 11:01:38 +10:00
psychedelicious
9d281388e0 tidy(ui): CanvasBrushLine 2024-08-27 11:01:38 +10:00
psychedelicious
178e1cc50b tidy(ui): CanvasBbox 2024-08-27 11:01:38 +10:00
psychedelicious
6eafe53b2d tidy(ui): CanvasBackground 2024-08-27 11:01:38 +10:00
psychedelicious
7514b2b7d4 tidy(ui): update canvas classes, organise location of konva nodes 2024-08-27 11:01:38 +10:00
psychedelicious
51dee2dba2 feat(ui): add names to all konva objects
Makes troubleshooting much simpler
2024-08-27 11:01:38 +10:00
psychedelicious
a81c3d841c fix(ui): do not await creating new canvas image
If you await this, it causes a race condition where multiple images are created.
2024-08-27 11:01:38 +10:00
psychedelicious
154487f966 feat(ui): use position and dimensions instead of separate x,y,width,height attrs 2024-08-27 11:01:38 +10:00
psychedelicious
588daafcf5 fix(ui): remove weird rtkq hook wrapper
I do not understand why I did that initially but it doesn't work with TS.
2024-08-27 11:01:38 +10:00
psychedelicious
ab00097aed feat(ui): rename types size and position to dimensions and coordinate 2024-08-27 11:01:38 +10:00
psychedelicious
5aefae71a2 tidy(ui): hide layer settings by default 2024-08-27 11:01:38 +10:00
psychedelicious
0edd598970 fix(ui): layer rendering when starting as disabled 2024-08-27 11:01:38 +10:00
psychedelicious
0bb485031f feat(invocation): reduce canvas v2 mask & crop mask dilation 2024-08-27 11:01:38 +10:00
psychedelicious
01ffd86367 feat(ui): de-jank staging area and progress images 2024-08-27 11:01:38 +10:00
psychedelicious
e2e02f31b6 feat(ui): update staging handling to work w/ cropped mask 2024-08-27 11:01:38 +10:00
psychedelicious
0008617348 chore(ui): typegen 2024-08-27 11:01:38 +10:00
psychedelicious
f8f21c0edd feat(app): update CanvasV2MaskAndCropInvocation 2024-08-27 11:01:38 +10:00
psychedelicious
010916158b feat(ui): use new canvas output node 2024-08-27 11:01:38 +10:00
psychedelicious
df0ba004ca chore(ui): typegen 2024-08-27 11:01:38 +10:00
psychedelicious
038b29e15b feat(app): add CanvasV2MaskAndCropInvocation & CanvasV2MaskAndCropOutput
This handles some masking and cropping that the canvas needs.
2024-08-27 11:01:21 +10:00
psychedelicious
763ab73923 fix(ui): restore nodes output tracking 2024-08-27 11:01:21 +10:00
psychedelicious
9a060b4437 feat(ui): rip out document size
barely knew ye
2024-08-27 11:01:21 +10:00
psychedelicious
2307a30892 feat(ui): convert initial image to layer when starting canvas session 2024-08-27 11:01:21 +10:00
psychedelicious
2006f84f6e fix(ui): fix layer transparency calculation 2024-08-27 11:01:21 +10:00
psychedelicious
23b15fef6a fix(ui): reset initial image when resetting canvas 2024-08-27 11:01:21 +10:00
psychedelicious
41e72f929d fix(ui): reset node executions states when loading workflow 2024-08-27 11:01:21 +10:00
psychedelicious
29fc49bb3b fix(ui): entity display list 2024-08-27 11:01:21 +10:00
psychedelicious
6f65b6a40f feat(ui): img2img working 2024-08-27 11:01:21 +10:00
psychedelicious
1e9b22e3a4 feat(ui): rough out img2img on canvas 2024-08-27 11:01:21 +10:00
psychedelicious
3dc2c723c3 UNDO ME WIP 2024-08-27 11:01:21 +10:00
psychedelicious
6f007cbd48 feat(ui): log invocation source id on socket event 2024-08-27 11:01:21 +10:00
psychedelicious
9a402dd10e feat(ui): restore document size overlay renderer 2024-08-27 11:01:21 +10:00
psychedelicious
4249d0e13b feat(ui): make documnet size a rect 2024-08-27 11:01:21 +10:00
psychedelicious
f6050bad67 refactor(ui): remove modular imagesize components
This is no longer necessary with canvas v2 and added a ton of extraneous redux actions when changing the image size. Also renamed to document size
2024-08-27 11:01:21 +10:00
psychedelicious
d3a4b7b51b feat(ui): initialState is for generation mode 2024-08-27 11:01:21 +10:00
psychedelicious
3f5f9ac764 feat(ui): split out canvas entity list component 2024-08-27 11:01:21 +10:00
psychedelicious
e7c1299a7f feat(ui): hide bbox button when no canvas session active 2024-08-27 11:01:21 +10:00
psychedelicious
56a3918a1e tidy(ui): remove unused naming objects/utils
The canvas manager means we don't need to worry about konva node names as we never directly select konva nodes.
2024-08-27 11:01:21 +10:00
psychedelicious
dabf7718cf feat(ui): split up tool chooser buttons
Prep for distinct toolbars for generation vs canvas modes
2024-08-27 11:01:21 +10:00
psychedelicious
ef22c29288 feat(ui): add useAssertSingleton util hook
This simple hook asserts that it is only ever called once. Particularly useful for things like hotkeys hooks.
2024-08-27 11:01:21 +10:00
psychedelicious
74f06074f7 feat(ui): "stagingArea" -> "session" 2024-08-27 11:01:21 +10:00
psychedelicious
b97bf52faa feat(ui): add reset button to canvas 2024-08-27 11:01:21 +10:00
psychedelicious
0a77f5cec8 feat(ui): add snapToRect util 2024-08-27 11:01:21 +10:00
psychedelicious
f8e92f7b73 fix(ui): fiddle with control adapter filters
some jank still
2024-08-27 11:01:21 +10:00
psychedelicious
530c6e3a59 feat(ui): temp disable doc size overlay 2024-08-27 11:01:21 +10:00
psychedelicious
11b95cfaf4 feat(ui): no animation on layer selection
Felt sluggish
2024-08-27 11:01:21 +10:00
psychedelicious
707c005a26 feat(ui): use canvas as source for control images (wip) 2024-08-27 11:01:21 +10:00
psychedelicious
19fa8e7e33 fix(ui): control adapter translate & scale 2024-08-27 11:01:21 +10:00
psychedelicious
b252ded366 tidy(ui): removed unused state related to non-buffered drawing 2024-08-27 11:01:21 +10:00
psychedelicious
84305d4e73 feat(ui): control adapter image rendering 2024-08-27 11:01:21 +10:00
psychedelicious
1150b41e14 fix(ui): do not floor bbox calc, it cuts off the last pixels 2024-08-27 11:01:21 +10:00
psychedelicious
88d8ccb34b feat(ui): fix issue where creating line needs 2 points 2024-08-27 11:01:21 +10:00
psychedelicious
4111b3f1aa fix(ui): edge cases when holding shift and drawing lines 2024-08-27 11:01:21 +10:00
psychedelicious
36862be2aa fix(ui): set buffered rect color to full alpha 2024-08-27 11:01:21 +10:00
psychedelicious
425665e0d9 fix(ui): handle mouseup correctly 2024-08-27 11:01:21 +10:00
psychedelicious
9abd604f69 feat(ui): buffered rect drawing 2024-08-27 11:01:21 +10:00
psychedelicious
59bdc288b5 fix(ui): buffered drawing edge cases 2024-08-27 11:01:21 +10:00
psychedelicious
eb37d2958e perf(ui): do not use stage.find 2024-08-27 11:01:21 +10:00
psychedelicious
2a9738a341 perf(ui): object groups do not listen 2024-08-27 11:01:21 +10:00
psychedelicious
6aac1cf33a perf(ui): buffered drawing (wip) 2024-08-27 11:01:21 +10:00
psychedelicious
9ca4d072ab tidy(ui): organise files 2024-08-27 11:01:21 +10:00
psychedelicious
7aaf14c26b tidy(ui): organise files 2024-08-27 11:01:21 +10:00
psychedelicious
cf598ca175 tidy(ui): organise files 2024-08-27 11:01:21 +10:00
psychedelicious
a722790afc fix(ui): background rendering 2024-08-27 11:01:21 +10:00
psychedelicious
320151a040 pkg(ui): remove unused deps react-konva & use-image 2024-08-27 11:01:21 +10:00
psychedelicious
c090f511c3 feat(ui): organize konva state and files 2024-08-27 11:01:21 +10:00
psychedelicious
86dd1475b3 fix(ui): merge conflicts in image deletion listener 2024-08-27 11:01:21 +10:00
psychedelicious
0b71ac258c fix(ui): region rendering 2024-08-27 11:01:21 +10:00
psychedelicious
54e1eae509 fix(ui): inpaint mask rendering 2024-08-27 11:01:21 +10:00
psychedelicious
bf57b2dc77 fix(ui): staging area rendering 2024-08-27 11:01:21 +10:00
psychedelicious
de3c27b44f fix(ui): stale selected entity 2024-08-27 11:01:21 +10:00
psychedelicious
05717fea93 fix(ui): staging area image offset 2024-08-27 11:01:21 +10:00
psychedelicious
191584d229 feat(ui): tweak layer ui component 2024-08-27 11:01:21 +10:00
psychedelicious
6069169e6b fix(ui): resetting layer resets position 2024-08-27 11:01:21 +10:00
psychedelicious
07438587f3 feat(ui): updated layer list component styling 2024-08-27 11:01:21 +10:00
psychedelicious
913e36d6fd feat(ui): transformable layers 2024-08-27 11:01:21 +10:00
psychedelicious
139004b976 feat(ui): move tool icon is pointer like in other apps 2024-08-27 11:01:21 +10:00
psychedelicious
ef4269d585 feat(ui): do not floor cursor position 2024-08-27 11:01:21 +10:00
psychedelicious
954cb129a4 feat(ui): disable gallery hotkeys while staging 2024-08-27 11:01:21 +10:00
psychedelicious
02c4b28de5 feat(ui): revised canvas progress & staging image handling 2024-08-27 11:01:21 +10:00
psychedelicious
febea88b58 feat(ui): show queue item origin in queue list 2024-08-27 11:01:21 +10:00
psychedelicious
40ccfac514 chore(ui): typegen 2024-08-27 11:01:21 +10:00
psychedelicious
831fb814cc feat(app): add origin to session queue
The origin is an optional field indicating the queue item's origin. For example, "canvas" when the queue item originated from the canvas or "workflows" when the queue item originated from the workflows tab. If omitted, we assume the queue item originated from the API directly.

- Add migration to add the nullable column to the `session_queue` table.
- Update relevant event payloads with the new field.
- Add `cancel_by_origin` method to `session_queue` service and corresponding route. This is required for the canvas to bail out early when staging images.
- Add `origin` to both `SessionQueueItem` and `Batch` - it needs to be provided initially via the batch and then passed onto the queue item.
-
2024-08-27 11:01:21 +10:00
psychedelicious
bf166fdd61 fix(ui): denoise start on outpainting 2024-08-27 11:01:21 +10:00
psychedelicious
384bde3539 feat(ui): add redux events for queue cleared & batch enqueued socket events 2024-08-27 11:01:21 +10:00
psychedelicious
6f1d238d0a feat(ui): canvas staging area works 2024-08-27 11:01:21 +10:00
psychedelicious
ac524153a7 feat(ui): switch to view tool when staging 2024-08-27 11:01:21 +10:00
psychedelicious
2cad2b15cf tidy(ui): disable preview images on every enqueue 2024-08-27 11:01:21 +10:00
psychedelicious
fd63e202fe feat(ui): rough out save staging image 2024-08-27 11:01:21 +10:00
psychedelicious
a0250e47e3 feat(ui): staging area image visibility toggle 2024-08-27 11:01:21 +10:00
psychedelicious
7d8ece45bb fix(ui): batch building after removing canvas files 2024-08-27 11:01:21 +10:00
psychedelicious
ffb8f053da feat(ui): make Graph class's getMetadataNode public 2024-08-27 11:01:21 +10:00
psychedelicious
fb46f457f9 tidy(ui): remove old canvas graphs 2024-08-27 11:01:21 +10:00
psychedelicious
6d4f4152a7 fix(ui): do not select already-selected entity 2024-08-27 11:01:20 +10:00
psychedelicious
d3d0ac7327 tidy(ui): naming things 2024-08-27 11:01:20 +10:00
psychedelicious
f57df46995 tidy(ui): file organisation 2024-08-27 11:01:20 +10:00
psychedelicious
a747171745 fix(ui): reset cursor pos when fitting document 2024-08-27 11:01:20 +10:00
psychedelicious
e9ae9e80d4 feat(ui): staging area works more better 2024-08-27 11:01:20 +10:00
psychedelicious
3b9a59b98d feat(ui): staging area barely works 2024-08-27 11:01:20 +10:00
psychedelicious
8a381e7f74 feat(ui): consolidate konva API 2024-08-27 11:01:20 +10:00
psychedelicious
61513fc800 feat(ui): consolidate konva API 2024-08-27 11:01:20 +10:00
psychedelicious
1d26c49e92 feat(ui): staging area (rendering wip) 2024-08-27 11:01:20 +10:00
psychedelicious
77be9836d2 tidy(ui): type "Dimensions" -> "Size" 2024-08-27 11:01:20 +10:00
psychedelicious
4427960acb feat(ui): add updateNode to Graph 2024-08-27 11:01:20 +10:00
psychedelicious
84aa4fb7bc feat(ui): sdxl graphs 2024-08-27 11:01:20 +10:00
psychedelicious
01df96cbe0 feat(ui): sd1 outpaint graph 2024-08-27 11:01:20 +10:00
psychedelicious
1ac0634f57 tests(ui): add missing tests for Graph class 2024-08-27 11:01:20 +10:00
psychedelicious
8e7d3634b1 feat(ui): add Graph.getid() util 2024-08-27 11:01:20 +10:00
psychedelicious
fadafe5c77 feat(ui): outpaint graph, organize builder a bit 2024-08-27 11:01:20 +10:00
psychedelicious
b2ea1f6690 feat(ui): inpaint sd1 graph 2024-08-27 11:01:20 +10:00
psychedelicious
0a03c1f882 feat(ui): temp disable image caching while testing 2024-08-27 11:01:20 +10:00
psychedelicious
ce8b490ed8 feat(ui): txt2img & img2img graphs 2024-08-27 11:01:20 +10:00
psychedelicious
86eccba80d feat(ui): minor change to canvas bbox state type 2024-08-27 11:01:20 +10:00
psychedelicious
97453e7c6c feat(ui): simplified konva node to blob/imagedata utils 2024-08-27 11:01:20 +10:00
psychedelicious
9e1084b701 feat(ui): node manager getter/setter 2024-08-27 11:01:20 +10:00
psychedelicious
41aec81f3f feat(ui): generation mode calculation, fudged graphs 2024-08-27 11:01:20 +10:00
psychedelicious
a5741a0551 feat(ui): add utils for getting images from canvas 2024-08-27 11:01:20 +10:00
psychedelicious
72f73c231a feat(ui): even more simplified API - lean on the konva node manager to abstract imperative state API & rendering 2024-08-27 11:01:20 +10:00
psychedelicious
b8fcaa274e feat(ui): revised docstrings for renderers & simplified api 2024-08-27 11:01:20 +10:00
psychedelicious
a33bbf48bb feat(ui): inpaint mask UI components 2024-08-27 11:01:20 +10:00
psychedelicious
98d9490fb9 feat(ui): inpaint mask rendering (wip) 2024-08-27 11:01:20 +10:00
psychedelicious
51c643c4f8 fix(ui): models loaded handler 2024-08-27 11:01:20 +10:00
psychedelicious
2d7370ca6c feat(ui): internal state for inpaint mask 2024-08-27 11:01:20 +10:00
psychedelicious
0d68141387 refactor(ui): divvy up canvas state a bit 2024-08-27 11:01:20 +10:00
psychedelicious
e88a8c6639 feat(ui): get region and base layer canvas to blob logic working 2024-08-27 11:01:20 +10:00
psychedelicious
2d04bb286e refactor(ui): node manager handles more tedious annoying stuff 2024-08-27 11:01:20 +10:00
psychedelicious
6d9ba24c32 feat(ui): use node manager for addRegions 2024-08-27 11:01:20 +10:00
psychedelicious
7645a1c86e feat(ui): persist bbox 2024-08-27 11:01:20 +10:00
psychedelicious
dc284b9a48 fix(ui): fix generation graphs 2024-08-27 11:01:20 +10:00
psychedelicious
8a38332d44 feat(ui): add toggle for clipToBbox 2024-08-27 11:01:20 +10:00
psychedelicious
2407d7d148 feat(ui): rename konva node manager 2024-08-27 11:01:20 +10:00
psychedelicious
23275cf607 refactor(ui): create classes to abstract mgmt of konva nodes 2024-08-27 11:01:20 +10:00
psychedelicious
8a0e02d335 tidy(ui): organise renderers 2024-08-27 11:01:20 +10:00
psychedelicious
913873623c refactor(ui): create entity to konva node map abstraction (wip)
Instead of chaining konva `find` and `findOne` methods, all konva nodes are added to a mapping object. Finding and manipulating them is much simpler.

Done for regions and layers, wip for control adapters.
2024-08-27 11:01:20 +10:00
psychedelicious
5d81e7dd4d perf(ui): fix lag w/ region rendering
Needed to memoize these selectors
2024-08-27 11:01:20 +10:00
psychedelicious
418650fdf3 feat(ui): move canvas fill color picker to right 2024-08-27 11:01:20 +10:00
psychedelicious
7c24e56d9f refactor(ui): remove unused ellipse & polygon objects 2024-08-27 11:01:20 +10:00
psychedelicious
f6faed46c3 fix(ui): incorrect rect/brush/eraser positions 2024-08-27 11:01:20 +10:00
psychedelicious
1d393eecf1 refactor(ui): enable global debugging flag 2024-08-27 11:01:20 +10:00
psychedelicious
03a72240c0 refactor(ui): disable the preview renderer for now 2024-08-27 11:01:20 +10:00
psychedelicious
acf62450fb tweak(ui): canvas editor layout 2024-08-27 11:01:20 +10:00
psychedelicious
d0269310cf perf(ui): memoize layeractionsmenu valid actions 2024-08-27 11:01:20 +10:00
psychedelicious
43618a74e7 refactor(ui): decouple konva renderer from react
Subscribe to redux store directly, skipping all the react overhead.

With react in dev mode, a typical frame while using the brush tool on almost-empty canvas is reduced from ~7.5ms to ~3.5ms. All things considered, this still feels slow, but it's a massive improvement.
2024-08-27 11:01:20 +10:00
psychedelicious
56fed637ec feat(ui): clip lines to bbox 2024-08-27 11:01:20 +10:00
psychedelicious
944ae4a604 fix(ui): document fit positioning 2024-08-27 11:01:20 +10:00
psychedelicious
f3da609102 feat(ui): document bounds overlay 2024-08-27 11:01:20 +10:00
psychedelicious
11c8a8cf72 tidy(ui): background layer 2024-08-27 11:01:20 +10:00
psychedelicious
656978676f refactor(ui): use "entity" instead of "data" for canvas 2024-08-27 11:01:20 +10:00
psychedelicious
d216e6519f feat(ui): brush size border radius = 1 2024-08-27 11:01:20 +10:00
psychedelicious
0a2bcae0e3 fix(ui): canvas HUD doesn't interrupt tool 2024-08-27 11:01:20 +10:00
psychedelicious
31cf244420 refactor(ui): split up canvas entity renderers, temp disable preview 2024-08-27 11:01:20 +10:00
psychedelicious
d761effac1 fix(ui): delete all layers button 2024-08-27 11:01:20 +10:00
psychedelicious
5efaf2c661 fix(ui): ignore keyboard shortcuts in input/textarea elements 2024-08-27 11:01:20 +10:00
psychedelicious
b79161fbec fix(ui): canvas entity ids getting clobbered 2024-08-27 11:01:20 +10:00
psychedelicious
89a2f2134b fix(ui): move lora followup fixes 2024-08-27 11:01:20 +10:00
psychedelicious
343c3b19b1 chore(ui): lint 2024-08-27 11:01:20 +10:00
psychedelicious
e6ec646b2c refactor(ui): move loras to canvas slice 2024-08-27 11:01:20 +10:00
psychedelicious
b557fe4e40 fix(ui): layer is selected when added 2024-08-27 11:01:20 +10:00
psychedelicious
45908dfbd2 feat(ui): r to center & fit stage on document 2024-08-27 11:01:20 +10:00
psychedelicious
1cf0673a22 feat(ui): better HUD 2024-08-27 11:01:20 +10:00
psychedelicious
189847f8a5 fix(ui): always use current brush width when making straight lines 2024-08-27 11:01:20 +10:00
psychedelicious
b27929f702 feat(ui): hold shift w/ brush to draw straight line 2024-08-27 11:01:20 +10:00
psychedelicious
b1a6a9835d fix(ui): update bg on canvas resize 2024-08-27 11:01:20 +10:00
psychedelicious
5564a16d4b refactor(ui): better hud 2024-08-27 11:01:20 +10:00
psychedelicious
b2aa447d50 refactor(ui): scaled tool preview border 2024-08-27 11:01:20 +10:00
psychedelicious
8666f9a848 refactor(ui): port remaining canvasV1 rendering logic to V2, remove old code 2024-08-27 11:01:20 +10:00
psychedelicious
e7dc7c4917 refactor(ui): fix more types 2024-08-27 11:01:20 +10:00
psychedelicious
513d0f1e5c refactor(ui): metadata recall (wip)
just enough let the app run
2024-08-27 11:01:20 +10:00
psychedelicious
c6ce7618cf refactor(ui): undo/redo button temp fix 2024-08-27 11:01:20 +10:00
psychedelicious
9b78b8dc91 refactor(ui): fix renderer stuff 2024-08-27 11:01:20 +10:00
psychedelicious
3bae233f40 refactor(ui): fix misc types 2024-08-27 11:01:20 +10:00
psychedelicious
0a305c4291 refactor(ui): fix gallery stuff 2024-08-27 11:01:20 +10:00
psychedelicious
54fe4ddf3e refactor(ui): fix delete image stuff 2024-08-27 11:01:20 +10:00
psychedelicious
aa877b981c refactor(ui): fix useIsReadyToEnqueue for new adapterType field 2024-08-27 11:01:20 +10:00
psychedelicious
b8ec4348a5 refactor(ui): update generation tab graphs 2024-08-27 11:01:20 +10:00
psychedelicious
0e3e27668c refactor(ui): add adapterType to ControlAdapterData 2024-08-27 11:01:20 +10:00
psychedelicious
e8c8025119 refactor(ui): update components & logic to use new unified slice (again) 2024-08-27 11:01:20 +10:00
psychedelicious
6a72eda5d2 refactor(ui): update components & logic to use new unified slice 2024-08-27 11:01:20 +10:00
psychedelicious
35c8c54466 refactor(ui): merge compositing, params into canvasV2 slice 2024-08-27 10:59:24 +10:00
psychedelicious
2eda45ca5b refactor(ui): add scaled bbox state 2024-08-27 10:59:24 +10:00
psychedelicious
ecd6e7960c refactor(ui): update dnd/image upload 2024-08-27 10:59:24 +10:00
psychedelicious
0d3a61cbdb refactor(ui): update size/prompts state 2024-08-27 10:59:24 +10:00
psychedelicious
6737c275d7 refactor(ui): rip out old control adapter implementation 2024-08-27 10:59:24 +10:00
psychedelicious
201a3e5838 refactor(ui): canvas v2 (wip)
fix entity count select
2024-08-27 10:59:24 +10:00
psychedelicious
85cb239219 refactor(ui): canvas v2 (wip)
delete unused file
2024-08-27 10:59:24 +10:00
psychedelicious
002f45e383 refactor(ui): canvas v2 (wip)
merge all canvas state reducers into one big slice (but with the logic split across files so it's not hell)
2024-08-27 10:59:24 +10:00
psychedelicious
470e5ba290 refactor(ui): canvas v2 (wip)
Fix a few more components
2024-08-27 10:59:24 +10:00
psychedelicious
b6722b3a10 refactor(ui): canvas v2 (wip)
missed a spot
2024-08-27 10:59:24 +10:00
psychedelicious
6a624916ca refactor(ui): canvas v2 (wip)
Redo all UI components for different canvas entity types
2024-08-27 10:59:24 +10:00
psychedelicious
65653e0932 refactor(ui): canvas v2 (wip) 2024-08-27 10:59:24 +10:00
psychedelicious
a7e59d6697 refactor(ui): canvas v2 (wip) 2024-08-27 10:59:24 +10:00
psychedelicious
f1356105c1 refactor(ui): canvas v2 (wip) 2024-08-27 10:59:24 +10:00
psychedelicious
8acc6379fb refactor(ui): canvas v2 (wip) 2024-08-27 10:59:24 +10:00
psychedelicious
d13014c5d9 feat(ui): bbox tool 2024-08-27 10:59:24 +10:00
psychedelicious
db11d8ba90 fix(ui): rect tool preview 2024-08-27 10:59:24 +10:00
psychedelicious
46a4ee2360 fix(ui): multiple stages 2024-08-27 10:59:24 +10:00
psychedelicious
a19c053b88 feat(ui): decouple konva logic from nanostores 2024-08-27 10:59:24 +10:00
psychedelicious
5b47a32d31 feat(ui): store all stage attrs in nanostores 2024-08-27 10:59:24 +10:00
psychedelicious
32ae9efb6a feat(ui): round stage scale 2024-08-27 10:59:24 +10:00
psychedelicious
b81bee551a chore(ui): bump konva 2024-08-27 10:59:24 +10:00
psychedelicious
8c9dffd082 feat(ui): generation bbox transformation working
whew
2024-08-27 10:59:24 +10:00
psychedelicious
cdfae643e4 feat(ui): wip generation bbox 2024-08-27 10:59:24 +10:00
psychedelicious
cb93108206 feat(ui): wip generation bbox 2024-08-27 10:59:24 +10:00
psychedelicious
c11343dc1c feat(ui): CL zoom and pan, some rendering optimizations 2024-08-27 10:59:24 +10:00
psychedelicious
3733c6f89d Revert "feat(ui): add x,y,scaleX,scaleY,rotation to objects"
This reverts commit 53318b396c967c72326a7e4dea09667b2ab20bdd.
2024-08-27 10:59:24 +10:00
psychedelicious
3c23a0eac0 feat(ui): layers manage their own bbox 2024-08-27 10:59:24 +10:00
psychedelicious
7fd69ab1f1 docs(ui): konva image object docstrings 2024-08-27 10:59:24 +10:00
psychedelicious
3f511774de feat(ui): add x,y,scaleX,scaleY,rotation to objects 2024-08-27 10:59:24 +10:00
psychedelicious
3600100879 fix(ui): show color picker when using rect tool 2024-08-27 10:59:24 +10:00
psychedelicious
8fae372103 feat(ui): image loading fallback for raster layers 2024-08-27 10:59:24 +10:00
psychedelicious
2fce1fe04c feat(ui): bbox calc for raster layers 2024-08-27 10:59:24 +10:00
psychedelicious
22f3e975c7 feat(ui): do not fill brush preview when drawing 2024-08-27 10:59:24 +10:00
psychedelicious
c6ced5a210 fix(ui): brush spacing handling 2024-08-27 10:59:24 +10:00
psychedelicious
5d66e85205 fix(ui): jank when starting a shape when not already focused on stage 2024-08-27 10:59:24 +10:00
psychedelicious
649f163bf7 feat(ui): wip raster layers
I meant to split this up into smaller commits and undo some of it, but I committed afterwards and it's tedious to undo.
2024-08-27 10:59:24 +10:00
psychedelicious
4c821bd930 feat(ui): support image objects on raster layers
Just the UI and internal state, not rendering yet.
2024-08-27 10:59:24 +10:00
psychedelicious
6359de4e25 tidy(ui): clean up event handlers
Separate logic for each tool in preparation for ellipse and polygon tools.
2024-08-27 10:59:24 +10:00
psychedelicious
2c610c8cd4 feat(ui): raster layer reset, object group util 2024-08-27 10:59:24 +10:00
psychedelicious
7efe8a249b feat(ui): rect shape preview now has fill 2024-08-27 10:59:24 +10:00
psychedelicious
bd421d184e feat(ui): cancel shape drawing on esc 2024-08-27 10:59:24 +10:00
psychedelicious
78f5844ba0 feat(ui): temp disable history on CL 2024-08-27 10:59:23 +10:00
psychedelicious
f6bb4d5051 feat(ui): raster layer logic
- Deduplicate shared logic
- Split up giant renderers file into separate cohesive files
- Tons of cleanup
- Progress on raster layer functionality
2024-08-27 10:59:23 +10:00
psychedelicious
56642c3e87 feat(ui): add raster layer rendering and interaction (WIP) 2024-08-27 10:59:23 +10:00
psychedelicious
9fd8678d3d feat(ui): scaffold out raster layers
Raster layers may have images, lines and shapes. These will replace initial image layers and provide sketching functionality like we have on canvas.
2024-08-27 10:59:23 +10:00
psychedelicious
8b89518fd6 refactor(ui): revise types for line and rect objects
- Create separate object types for brush and eraser lines, instead of a single type that has a `tool` field.
- Create new object type for rect shapes.
- Add logic to schemas to migrate old object types to new.
- Update renderers & reducers.
2024-08-27 10:59:23 +10:00
174 changed files with 2926 additions and 4837 deletions

View File

@@ -45,13 +45,11 @@ class UIType(str, Enum, metaclass=MetaEnum):
SDXLRefinerModel = "SDXLRefinerModelField"
ONNXModel = "ONNXModelField"
VAEModel = "VAEModelField"
FluxVAEModel = "FluxVAEModelField"
LoRAModel = "LoRAModelField"
ControlNetModel = "ControlNetModelField"
IPAdapterModel = "IPAdapterModelField"
T2IAdapterModel = "T2IAdapterModelField"
T5EncoderModel = "T5EncoderModelField"
CLIPEmbedModel = "CLIPEmbedModelField"
SpandrelImageToImageModel = "SpandrelImageToImageModelField"
# endregion
@@ -130,7 +128,6 @@ class FieldDescriptions:
noise = "Noise tensor"
clip = "CLIP (tokenizer, text encoder, LoRAs) and skipped layer count"
t5_encoder = "T5 tokenizer and text encoder"
clip_embed_model = "CLIP Embed loader"
unet = "UNet (scheduler, LoRAs)"
transformer = "Transformer"
vae = "VAE"

View File

@@ -40,10 +40,7 @@ class FluxTextEncoderInvocation(BaseInvocation):
@torch.no_grad()
def invoke(self, context: InvocationContext) -> FluxConditioningOutput:
# Note: The T5 and CLIP encoding are done in separate functions to ensure that all model references are locally
# scoped. This ensures that the T5 model can be freed and gc'd before loading the CLIP model (if necessary).
t5_embeddings = self._t5_encode(context)
clip_embeddings = self._clip_encode(context)
t5_embeddings, clip_embeddings = self._encode_prompt(context)
conditioning_data = ConditioningFieldData(
conditionings=[FLUXConditioningInfo(clip_embeds=clip_embeddings, t5_embeds=t5_embeddings)]
)
@@ -51,7 +48,12 @@ class FluxTextEncoderInvocation(BaseInvocation):
conditioning_name = context.conditioning.save(conditioning_data)
return FluxConditioningOutput.build(conditioning_name)
def _t5_encode(self, context: InvocationContext) -> torch.Tensor:
def _encode_prompt(self, context: InvocationContext) -> tuple[torch.Tensor, torch.Tensor]:
# Load CLIP.
clip_tokenizer_info = context.models.load(self.clip.tokenizer)
clip_text_encoder_info = context.models.load(self.clip.text_encoder)
# Load T5.
t5_tokenizer_info = context.models.load(self.t5_encoder.tokenizer)
t5_text_encoder_info = context.models.load(self.t5_encoder.text_encoder)
@@ -68,15 +70,6 @@ class FluxTextEncoderInvocation(BaseInvocation):
prompt_embeds = t5_encoder(prompt)
assert isinstance(prompt_embeds, torch.Tensor)
return prompt_embeds
def _clip_encode(self, context: InvocationContext) -> torch.Tensor:
clip_tokenizer_info = context.models.load(self.clip.tokenizer)
clip_text_encoder_info = context.models.load(self.clip.text_encoder)
prompt = [self.prompt]
with (
clip_text_encoder_info as clip_text_encoder,
clip_tokenizer_info as clip_tokenizer,
@@ -88,5 +81,6 @@ class FluxTextEncoderInvocation(BaseInvocation):
pooled_prompt_embeds = clip_encoder(prompt)
assert isinstance(prompt_embeds, torch.Tensor)
assert isinstance(pooled_prompt_embeds, torch.Tensor)
return pooled_prompt_embeds
return prompt_embeds, pooled_prompt_embeds

View File

@@ -58,7 +58,13 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
@torch.no_grad()
def invoke(self, context: InvocationContext) -> ImageOutput:
latents = self._run_diffusion(context)
# Load the conditioning data.
cond_data = context.conditioning.load(self.positive_text_conditioning.conditioning_name)
assert len(cond_data.conditionings) == 1
flux_conditioning = cond_data.conditionings[0]
assert isinstance(flux_conditioning, FLUXConditioningInfo)
latents = self._run_diffusion(context, flux_conditioning.clip_embeds, flux_conditioning.t5_embeds)
image = self._run_vae_decoding(context, latents)
image_dto = context.images.save(image=image)
return ImageOutput.build(image_dto)
@@ -66,19 +72,11 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
def _run_diffusion(
self,
context: InvocationContext,
clip_embeddings: torch.Tensor,
t5_embeddings: torch.Tensor,
):
inference_dtype = torch.bfloat16
# Load the conditioning data.
cond_data = context.conditioning.load(self.positive_text_conditioning.conditioning_name)
assert len(cond_data.conditionings) == 1
flux_conditioning = cond_data.conditionings[0]
assert isinstance(flux_conditioning, FLUXConditioningInfo)
flux_conditioning = flux_conditioning.to(dtype=inference_dtype)
t5_embeddings = flux_conditioning.t5_embeds
clip_embeddings = flux_conditioning.clip_embeds
transformer_info = context.models.load(self.transformer.transformer)
inference_dtype = torch.bfloat16
# Prepare input noise.
x = get_noise(
@@ -90,19 +88,24 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
seed=self.seed,
)
x, img_ids = prepare_latent_img_patches(x)
img, img_ids = prepare_latent_img_patches(x)
is_schnell = "schnell" in transformer_info.config.config_path
timesteps = get_schedule(
num_steps=self.num_steps,
image_seq_len=x.shape[1],
image_seq_len=img.shape[1],
shift=not is_schnell,
)
bs, t5_seq_len, _ = t5_embeddings.shape
txt_ids = torch.zeros(bs, t5_seq_len, 3, dtype=inference_dtype, device=TorchDevice.choose_torch_device())
# HACK(ryand): Manually empty the cache. Currently we don't check the size of the model before loading it from
# disk. Since the transformer model is large (24GB), there's a good chance that it will OOM on 32GB RAM systems
# if the cache is not empty.
context.models._services.model_manager.load.ram_cache.make_room(24 * 2**30)
with transformer_info as transformer:
assert isinstance(transformer, Flux)
@@ -137,7 +140,7 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
x = denoise(
model=transformer,
img=x,
img=img,
img_ids=img_ids,
txt=t5_embeddings,
txt_ids=txt_ids,

View File

@@ -157,7 +157,7 @@ class FluxModelLoaderOutput(BaseInvocationOutput):
title="Flux Main Model",
tags=["model", "flux"],
category="model",
version="1.0.4",
version="1.0.3",
classification=Classification.Prototype,
)
class FluxModelLoaderInvocation(BaseInvocation):
@@ -169,35 +169,23 @@ class FluxModelLoaderInvocation(BaseInvocation):
input=Input.Direct,
)
t5_encoder_model: ModelIdentifierField = InputField(
description=FieldDescriptions.t5_encoder, ui_type=UIType.T5EncoderModel, input=Input.Direct, title="T5 Encoder"
)
clip_embed_model: ModelIdentifierField = InputField(
description=FieldDescriptions.clip_embed_model,
ui_type=UIType.CLIPEmbedModel,
t5_encoder: ModelIdentifierField = InputField(
description=FieldDescriptions.t5_encoder,
ui_type=UIType.T5EncoderModel,
input=Input.Direct,
title="CLIP Embed",
)
vae_model: ModelIdentifierField = InputField(
description=FieldDescriptions.vae_model, ui_type=UIType.FluxVAEModel, title="VAE"
)
def invoke(self, context: InvocationContext) -> FluxModelLoaderOutput:
for key in [self.model.key, self.t5_encoder_model.key, self.clip_embed_model.key, self.vae_model.key]:
if not context.models.exists(key):
raise ValueError(f"Unknown model: {key}")
transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
tokenizer = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
clip_encoder = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
tokenizer2 = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer2})
t5_encoder = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder2})
model_key = self.model.key
if not context.models.exists(model_key):
raise ValueError(f"Unknown model: {model_key}")
transformer = self._get_model(context, SubModelType.Transformer)
tokenizer = self._get_model(context, SubModelType.Tokenizer)
tokenizer2 = self._get_model(context, SubModelType.Tokenizer2)
clip_encoder = self._get_model(context, SubModelType.TextEncoder)
t5_encoder = self._get_model(context, SubModelType.TextEncoder2)
vae = self._get_model(context, SubModelType.VAE)
transformer_config = context.models.get_config(transformer)
assert isinstance(transformer_config, CheckpointConfigBase)
@@ -209,6 +197,52 @@ class FluxModelLoaderInvocation(BaseInvocation):
max_seq_len=max_seq_lengths[transformer_config.config_path],
)
def _get_model(self, context: InvocationContext, submodel: SubModelType) -> ModelIdentifierField:
match submodel:
case SubModelType.Transformer:
return self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
case SubModelType.VAE:
return self._pull_model_from_mm(
context,
SubModelType.VAE,
"FLUX.1-schnell_ae",
ModelType.VAE,
BaseModelType.Flux,
)
case submodel if submodel in [SubModelType.Tokenizer, SubModelType.TextEncoder]:
return self._pull_model_from_mm(
context,
submodel,
"clip-vit-large-patch14",
ModelType.CLIPEmbed,
BaseModelType.Any,
)
case submodel if submodel in [SubModelType.Tokenizer2, SubModelType.TextEncoder2]:
return self._pull_model_from_mm(
context,
submodel,
self.t5_encoder.name,
ModelType.T5Encoder,
BaseModelType.Any,
)
case _:
raise Exception(f"{submodel.value} is not a supported submodule for a flux model")
def _pull_model_from_mm(
self,
context: InvocationContext,
submodel: SubModelType,
name: str,
type: ModelType,
base: BaseModelType,
):
if models := context.models.search_by_attrs(name=name, base=base, type=type):
if len(models) != 1:
raise Exception(f"Multiple models detected for selected model with name {name}")
return ModelIdentifierField.from_config(models[0]).model_copy(update={"submodel_type": submodel})
else:
raise ValueError(f"Please install the {base}:{type} model named {name} via starter models")
@invocation(
"main_model_loader",

View File

@@ -88,8 +88,7 @@ class QueueItemEventBase(QueueEventBase):
item_id: int = Field(description="The ID of the queue item")
batch_id: str = Field(description="The ID of the queue batch")
origin: str | None = Field(default=None, description="The origin of the queue item")
destination: str | None = Field(default=None, description="The destination of the queue item")
origin: str | None = Field(default=None, description="The origin of the batch")
class InvocationEventBase(QueueItemEventBase):
@@ -115,7 +114,6 @@ class InvocationStartedEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@@ -150,7 +148,6 @@ class InvocationDenoiseProgressEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@@ -189,7 +186,6 @@ class InvocationCompleteEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@@ -223,7 +219,6 @@ class InvocationErrorEvent(InvocationEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@@ -262,7 +257,6 @@ class QueueItemStatusChangedEvent(QueueItemEventBase):
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
origin=queue_item.origin,
destination=queue_item.destination,
session_id=queue_item.session_id,
status=queue_item.status,
error_type=queue_item.error_type,

View File

@@ -77,14 +77,7 @@ BatchDataCollection: TypeAlias = list[list[BatchDatum]]
class Batch(BaseModel):
batch_id: str = Field(default_factory=uuid_string, description="The ID of the batch")
origin: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
)
destination: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
)
origin: str | None = Field(default=None, description="The origin of this batch.")
data: Optional[BatchDataCollection] = Field(default=None, description="The batch data collection.")
graph: Graph = Field(description="The graph to initialize the session with")
workflow: Optional[WorkflowWithoutID] = Field(
@@ -203,14 +196,7 @@ class SessionQueueItemWithoutGraph(BaseModel):
status: QUEUE_ITEM_STATUS = Field(default="pending", description="The status of this queue item")
priority: int = Field(default=0, description="The priority of this queue item")
batch_id: str = Field(description="The ID of the batch associated with this queue item")
origin: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
)
destination: str | None = Field(
default=None,
description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
)
origin: str | None = Field(default=None, description="The origin of this queue item. ")
session_id: str = Field(
description="The ID of the session associated with this queue item. The session doesn't exist in graph_executions until the queue item is executed."
)
@@ -311,7 +297,6 @@ class BatchStatus(BaseModel):
queue_id: str = Field(..., description="The ID of the queue")
batch_id: str = Field(..., description="The ID of the batch")
origin: str | None = Field(..., description="The origin of the batch")
destination: str | None = Field(..., description="The destination of the batch")
pending: int = Field(..., description="Number of queue items with status 'pending'")
in_progress: int = Field(..., description="Number of queue items with status 'in_progress'")
completed: int = Field(..., description="Number of queue items with status 'complete'")
@@ -458,7 +443,6 @@ class SessionQueueValueToInsert(NamedTuple):
priority: int # priority
workflow: Optional[str] # workflow json
origin: str | None
destination: str | None
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
@@ -480,7 +464,6 @@ def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new
priority, # priority
json.dumps(workflow, default=to_jsonable_python) if workflow else None, # workflow (json)
batch.origin, # origin
batch.destination, # destination
)
)
return values_to_insert

View File

@@ -128,8 +128,8 @@ class SqliteSessionQueue(SessionQueueBase):
self.__cursor.executemany(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
values_to_insert,
)
@@ -579,8 +579,7 @@ class SqliteSessionQueue(SessionQueueBase):
session_id,
batch_id,
queue_id,
origin,
destination
origin
FROM session_queue
WHERE queue_id = ?
"""
@@ -660,7 +659,7 @@ class SqliteSessionQueue(SessionQueueBase):
self.__lock.acquire()
self.__cursor.execute(
"""--sql
SELECT status, count(*), origin, destination
SELECT status, count(*), origin
FROM session_queue
WHERE
queue_id = ?
@@ -673,7 +672,6 @@ class SqliteSessionQueue(SessionQueueBase):
total = sum(row[1] for row in result)
counts: dict[str, int] = {row[0]: row[1] for row in result}
origin = result[0]["origin"] if result else None
destination = result[0]["destination"] if result else None
except Exception:
self.__conn.rollback()
raise
@@ -683,7 +681,6 @@ class SqliteSessionQueue(SessionQueueBase):
return BatchStatus(
batch_id=batch_id,
origin=origin,
destination=destination,
queue_id=queue_id,
pending=counts.get("pending", 0),
in_progress=counts.get("in_progress", 0),

View File

@@ -10,11 +10,9 @@ class Migration15Callback:
def _add_origin_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN origin TEXT;")
cursor.execute("ALTER TABLE session_queue ADD COLUMN destination TEXT;")
def build_migration_15() -> Migration:
@@ -23,7 +21,6 @@ def build_migration_15() -> Migration:
This migration does the following:
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
migration_15 = Migration(
from_version=14,

View File

@@ -2,13 +2,13 @@
"name": "FLUX Text to Image",
"author": "InvokeAI",
"description": "A simple text-to-image workflow using FLUX dev or schnell models. Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend 4 steps for FLUX schnell models and 30 steps for FLUX dev models.",
"version": "1.0.4",
"version": "1.0.0",
"contact": "",
"tags": "text2image, flux",
"notes": "Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend 4 steps for FLUX schnell models and 30 steps for FLUX dev models.",
"exposedFields": [
{
"nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"nodeId": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"fieldName": "model"
},
{
@@ -20,8 +20,8 @@
"fieldName": "num_steps"
},
{
"nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"fieldName": "t5_encoder_model"
"nodeId": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"fieldName": "t5_encoder"
}
],
"meta": {
@@ -30,12 +30,12 @@
},
"nodes": [
{
"id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"id": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"type": "invocation",
"data": {
"id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"id": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"type": "flux_model_loader",
"version": "1.0.4",
"version": "1.0.3",
"label": "",
"notes": "",
"isOpen": true,
@@ -44,25 +44,31 @@
"inputs": {
"model": {
"name": "model",
"label": ""
"label": "Model (Starter Models can be found in Model Manager)",
"value": {
"key": "f04a7a2f-c74d-4538-8d5e-879a53501662",
"hash": "random:4875da7a9508444ffa706f61961c260d0c6729f6181a86b31fad06df1277b850",
"name": "FLUX Dev (Quantized)",
"base": "flux",
"type": "main"
}
},
"t5_encoder_model": {
"name": "t5_encoder_model",
"label": ""
},
"clip_embed_model": {
"name": "clip_embed_model",
"label": ""
},
"vae_model": {
"name": "vae_model",
"label": ""
"t5_encoder": {
"name": "t5_encoder",
"label": "T 5 Encoder (Starter Models can be found in Model Manager)",
"value": {
"key": "20dcd9ec-5fbb-4012-8401-049e707da5e5",
"hash": "random:f986be43ff3502169e4adbdcee158afb0e0a65a1edc4cab16ae59963630cfd8f",
"name": "t5_bnb_int8_quantized_encoder",
"base": "any",
"type": "t5_encoder"
}
}
}
},
"position": {
"x": 381.1882713063478,
"y": -95.89663532854017
"x": 337.09365228062825,
"y": 40.63469521079861
}
},
{
@@ -201,45 +207,45 @@
],
"edges": [
{
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90max_seq_len-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_max_seq_len",
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33amax_seq_len-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_max_seq_len",
"type": "default",
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
"sourceHandle": "max_seq_len",
"targetHandle": "t5_max_seq_len"
},
{
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90vae-159bdf1b-79e7-4174-b86e-d40e646964c8vae",
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33avae-159bdf1b-79e7-4174-b86e-d40e646964c8vae",
"type": "default",
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"target": "159bdf1b-79e7-4174-b86e-d40e646964c8",
"sourceHandle": "vae",
"targetHandle": "vae"
},
{
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90t5_encoder-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_encoder",
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33atransformer-159bdf1b-79e7-4174-b86e-d40e646964c8transformer",
"type": "default",
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"target": "159bdf1b-79e7-4174-b86e-d40e646964c8",
"sourceHandle": "transformer",
"targetHandle": "transformer"
},
{
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33at5_encoder-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_encoder",
"type": "default",
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
"sourceHandle": "t5_encoder",
"targetHandle": "t5_encoder"
},
{
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90clip-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cclip",
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33aclip-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cclip",
"type": "default",
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
"target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
"sourceHandle": "clip",
"targetHandle": "clip"
},
{
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90transformer-159bdf1b-79e7-4174-b86e-d40e646964c8transformer",
"type": "default",
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
"target": "159bdf1b-79e7-4174-b86e-d40e646964c8",
"sourceHandle": "transformer",
"targetHandle": "transformer"
},
{
"id": "reactflow__edge-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cconditioning-159bdf1b-79e7-4174-b86e-d40e646964c8positive_text_conditioning",
"type": "default",

View File

@@ -111,7 +111,16 @@ def denoise(
step_callback: Callable[[], None],
guidance: float = 4.0,
):
# guidance_vec is ignored for schnell.
dtype = model.txt_in.bias.dtype
# TODO(ryand): This shouldn't be necessary if we manage the dtypes properly in the caller.
img = img.to(dtype=dtype)
img_ids = img_ids.to(dtype=dtype)
txt = txt.to(dtype=dtype)
txt_ids = txt_ids.to(dtype=dtype)
vec = vec.to(dtype=dtype)
# this is ignored for schnell
guidance_vec = torch.full((img.shape[0],), guidance, device=img.device, dtype=img.dtype)
for t_curr, t_prev in tqdm(list(zip(timesteps[:-1], timesteps[1:], strict=True))):
t_vec = torch.full((img.shape[0],), t_curr, dtype=img.dtype, device=img.device)
@@ -159,9 +168,9 @@ def prepare_latent_img_patches(latent_img: torch.Tensor) -> tuple[torch.Tensor,
img = repeat(img, "1 ... -> bs ...", bs=bs)
# Generate patch position ids.
img_ids = torch.zeros(h // 2, w // 2, 3, device=img.device, dtype=img.dtype)
img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // 2, device=img.device, dtype=img.dtype)[:, None]
img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // 2, device=img.device, dtype=img.dtype)[None, :]
img_ids = torch.zeros(h // 2, w // 2, 3, device=img.device)
img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // 2, device=img.device)[:, None]
img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // 2, device=img.device)[None, :]
img_ids = repeat(img_ids, "h w c -> b (h w) c", b=bs)
return img, img_ids

View File

@@ -72,7 +72,6 @@ class ModelLoader(ModelLoaderBase):
pass
config.path = str(self._get_model_path(config))
self._ram_cache.make_room(self.get_size_fs(config, Path(config.path), submodel_type))
loaded_model = self._load_model(config, submodel_type)
self._ram_cache.put(

View File

@@ -193,6 +193,15 @@ class ModelCacheBase(ABC, Generic[T]):
"""
pass
@abstractmethod
def exists(
self,
key: str,
submodel_type: Optional[SubModelType] = None,
) -> bool:
"""Return true if the model identified by key and submodel_type is in the cache."""
pass
@abstractmethod
def cache_size(self) -> int:
"""Get the total size of the models currently cached."""

View File

@@ -1,6 +1,22 @@
# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Development team
# TODO: Add Stalker's proper name to copyright
""" """
"""
Manage a RAM cache of diffusion/transformer models for fast switching.
They are moved between GPU VRAM and CPU RAM as necessary. If the cache
grows larger than a preset maximum, then the least recently used
model will be cleared and (re)loaded from disk when next needed.
The cache returns context manager generators designed to load the
model into the GPU within the context, and unload outside the
context. Use like this:
cache = ModelCache(max_cache_size=7.5)
with cache.get_model('runwayml/stable-diffusion-1-5') as SD1,
cache.get_model('stabilityai/stable-diffusion-2') as SD2:
do_something_in_GPU(SD1,SD2)
"""
import gc
import math
@@ -24,64 +40,45 @@ from invokeai.backend.model_manager.load.model_util import calc_model_size_by_da
from invokeai.backend.util.devices import TorchDevice
from invokeai.backend.util.logging import InvokeAILogger
# Size of a GB in bytes.
GB = 2**30
# Maximum size of the cache, in gigs
# Default is roughly enough to hold three fp16 diffusers models in RAM simultaneously
DEFAULT_MAX_CACHE_SIZE = 6.0
# amount of GPU memory to hold in reserve for use by generations (GB)
DEFAULT_MAX_VRAM_CACHE_SIZE = 2.75
# actual size of a gig
GIG = 1073741824
# Size of a MB in bytes.
MB = 2**20
class ModelCache(ModelCacheBase[AnyModel]):
"""A cache for managing models in memory.
The cache is based on two levels of model storage:
- execution_device: The device where most models are executed (typically "cuda", "mps", or "cpu").
- storage_device: The device where models are offloaded when not in active use (typically "cpu").
The model cache is based on the following assumptions:
- storage_device_mem_size > execution_device_mem_size
- disk_to_storage_device_transfer_time >> storage_device_to_execution_device_transfer_time
A copy of all models in the cache is always kept on the storage_device. A subset of the models also have a copy on
the execution_device.
Models are moved between the storage_device and the execution_device as necessary. Cache size limits are enforced
on both the storage_device and the execution_device. The execution_device cache uses a smallest-first offload
policy. The storage_device cache uses a least-recently-used (LRU) offload policy.
Note: Neither of these offload policies has really been compared against alternatives. It's likely that different
policies would be better, although the optimal policies are likely heavily dependent on usage patterns and HW
configuration.
The cache returns context manager generators designed to load the model into the execution device (often GPU) within
the context, and unload outside the context.
Example usage:
```
cache = ModelCache(max_cache_size=7.5, max_vram_cache_size=6.0)
with cache.get_model('runwayml/stable-diffusion-1-5') as SD1:
do_something_on_gpu(SD1)
```
"""
"""Implementation of ModelCacheBase."""
def __init__(
self,
max_cache_size: float,
max_vram_cache_size: float,
max_cache_size: float = DEFAULT_MAX_CACHE_SIZE,
max_vram_cache_size: float = DEFAULT_MAX_VRAM_CACHE_SIZE,
execution_device: torch.device = torch.device("cuda"),
storage_device: torch.device = torch.device("cpu"),
precision: torch.dtype = torch.float16,
sequential_offload: bool = False,
lazy_offloading: bool = True,
sha_chunksize: int = 16777216,
log_memory_usage: bool = False,
logger: Optional[Logger] = None,
):
"""
Initialize the model RAM cache.
:param max_cache_size: Maximum size of the storage_device cache in GBs.
:param max_vram_cache_size: Maximum size of the execution_device cache in GBs.
:param max_cache_size: Maximum size of the RAM cache [6.0 GB]
:param execution_device: Torch device to load active model into [torch.device('cuda')]
:param storage_device: Torch device to save inactive model in [torch.device('cpu')]
:param lazy_offloading: Keep model in VRAM until another model needs to be loaded.
:param precision: Precision for loaded models [torch.float16]
:param lazy_offloading: Keep model in VRAM until another model needs to be loaded
:param sequential_offload: Conserve VRAM by loading and unloading each stage of the pipeline sequentially
:param log_memory_usage: If True, a memory snapshot will be captured before and after every model cache
operation, and the result will be logged (at debug level). There is a time cost to capturing the memory
snapshots, so it is recommended to disable this feature unless you are actively inspecting the model cache's
@@ -89,6 +86,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
"""
# allow lazy offloading only when vram cache enabled
self._lazy_offloading = lazy_offloading and max_vram_cache_size > 0
self._precision: torch.dtype = precision
self._max_cache_size: float = max_cache_size
self._max_vram_cache_size: float = max_vram_cache_size
self._execution_device: torch.device = execution_device
@@ -147,6 +145,15 @@ class ModelCache(ModelCacheBase[AnyModel]):
total += cache_record.size
return total
def exists(
self,
key: str,
submodel_type: Optional[SubModelType] = None,
) -> bool:
"""Return true if the model identified by key and submodel_type is in the cache."""
key = self._make_cache_key(key, submodel_type)
return key in self._cached_models
def put(
self,
key: str,
@@ -196,7 +203,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
# more stats
if self.stats:
stats_name = stats_name or key
self.stats.cache_size = int(self._max_cache_size * GB)
self.stats.cache_size = int(self._max_cache_size * GIG)
self.stats.high_watermark = max(self.stats.high_watermark, self.cache_size())
self.stats.in_cache = len(self._cached_models)
self.stats.loaded_model_sizes[stats_name] = max(
@@ -224,13 +231,10 @@ class ModelCache(ModelCacheBase[AnyModel]):
return model_key
def offload_unlocked_models(self, size_required: int) -> None:
"""Offload models from the execution_device to make room for size_required.
:param size_required: The amount of space to clear in the execution_device cache, in bytes.
"""
reserved = self._max_vram_cache_size * GB
"""Move any unused models from VRAM."""
reserved = self._max_vram_cache_size * GIG
vram_in_use = torch.cuda.memory_allocated() + size_required
self.logger.debug(f"{(vram_in_use/GB):.2f}GB VRAM needed for models; max allowed={(reserved/GB):.2f}GB")
self.logger.debug(f"{(vram_in_use/GIG):.2f}GB VRAM needed for models; max allowed={(reserved/GIG):.2f}GB")
for _, cache_entry in sorted(self._cached_models.items(), key=lambda x: x[1].size):
if vram_in_use <= reserved:
break
@@ -241,7 +245,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
cache_entry.loaded = False
vram_in_use = torch.cuda.memory_allocated() + size_required
self.logger.debug(
f"Removing {cache_entry.key} from VRAM to free {(cache_entry.size/GB):.2f}GB; vram free = {(torch.cuda.memory_allocated()/GB):.2f}GB"
f"Removing {cache_entry.key} from VRAM to free {(cache_entry.size/GIG):.2f}GB; vram free = {(torch.cuda.memory_allocated()/GIG):.2f}GB"
)
TorchDevice.empty_cache()
@@ -299,7 +303,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
self.logger.debug(
f"Moved model '{cache_entry.key}' from {source_device} to"
f" {target_device} in {(end_model_to_time-start_model_to_time):.2f}s."
f"Estimated model size: {(cache_entry.size/GB):.3f} GB."
f"Estimated model size: {(cache_entry.size/GIG):.3f} GB."
f"{get_pretty_snapshot_diff(snapshot_before, snapshot_after)}"
)
@@ -322,14 +326,14 @@ class ModelCache(ModelCacheBase[AnyModel]):
f"Moving model '{cache_entry.key}' from {source_device} to"
f" {target_device} caused an unexpected change in VRAM usage. The model's"
" estimated size may be incorrect. Estimated model size:"
f" {(cache_entry.size/GB):.3f} GB.\n"
f" {(cache_entry.size/GIG):.3f} GB.\n"
f"{get_pretty_snapshot_diff(snapshot_before, snapshot_after)}"
)
def print_cuda_stats(self) -> None:
"""Log CUDA diagnostics."""
vram = "%4.2fG" % (torch.cuda.memory_allocated() / GB)
ram = "%4.2fG" % (self.cache_size() / GB)
vram = "%4.2fG" % (torch.cuda.memory_allocated() / GIG)
ram = "%4.2fG" % (self.cache_size() / GIG)
in_ram_models = 0
in_vram_models = 0
@@ -349,20 +353,17 @@ class ModelCache(ModelCacheBase[AnyModel]):
)
def make_room(self, size: int) -> None:
"""Make enough room in the cache to accommodate a new model of indicated size.
Note: This function deletes all of the cache's internal references to a model in order to free it. If there are
external references to the model, there's nothing that the cache can do about it, and those models will not be
garbage-collected.
"""
"""Make enough room in the cache to accommodate a new model of indicated size."""
# calculate how much memory this model will require
# multiplier = 2 if self.precision==torch.float32 else 1
bytes_needed = size
maximum_size = self.max_cache_size * GB # stored in GB, convert to bytes
maximum_size = self.max_cache_size * GIG # stored in GB, convert to bytes
current_size = self.cache_size()
if current_size + bytes_needed > maximum_size:
self.logger.debug(
f"Max cache size exceeded: {(current_size/GB):.2f}/{self.max_cache_size:.2f} GB, need an additional"
f" {(bytes_needed/GB):.2f} GB"
f"Max cache size exceeded: {(current_size/GIG):.2f}/{self.max_cache_size:.2f} GB, need an additional"
f" {(bytes_needed/GIG):.2f} GB"
)
self.logger.debug(f"Before making_room: cached_models={len(self._cached_models)}")
@@ -379,7 +380,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
if not cache_entry.locked:
self.logger.debug(
f"Removing {model_key} from RAM cache to free at least {(size/GB):.2f} GB (-{(cache_entry.size/GB):.2f} GB)"
f"Removing {model_key} from RAM cache to free at least {(size/GIG):.2f} GB (-{(cache_entry.size/GIG):.2f} GB)"
)
current_size -= cache_entry.size
models_cleared += 1

View File

@@ -54,10 +54,8 @@ class InvokeLinear8bitLt(bnb.nn.Linear8bitLt):
# See `bnb.nn.Linear8bitLt._save_to_state_dict()` for the serialization logic of SCB and weight_format.
scb = state_dict.pop(prefix + "SCB", None)
# Currently, we only support weight_format=0.
weight_format = state_dict.pop(prefix + "weight_format", None)
assert weight_format == 0
# weight_format is unused, but we pop it so we can validate that there are no unexpected keys.
_weight_format = state_dict.pop(prefix + "weight_format", None)
# TODO(ryand): Technically, we should be using `strict`, `missing_keys`, `unexpected_keys`, and `error_msgs`
# rather than raising an exception to correctly implement this API.
@@ -91,14 +89,6 @@ class InvokeLinear8bitLt(bnb.nn.Linear8bitLt):
)
self.bias = bias if bias is None else torch.nn.Parameter(bias)
# Reset the state. The persisted fields are based on the initialization behaviour in
# `bnb.nn.Linear8bitLt.__init__()`.
new_state = bnb.MatmulLtState()
new_state.threshold = self.state.threshold
new_state.has_fp16_weights = False
new_state.use_pool = self.state.use_pool
self.state = new_state
def _convert_linear_layers_to_llm_8bit(
module: torch.nn.Module, ignore_modules: set[str], outlier_threshold: float, prefix: str = ""

View File

@@ -43,11 +43,6 @@ class FLUXConditioningInfo:
clip_embeds: torch.Tensor
t5_embeds: torch.Tensor
def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None):
self.clip_embeds = self.clip_embeds.to(device=device, dtype=dtype)
self.t5_embeds = self.t5_embeds.to(device=device, dtype=dtype)
return self
@dataclass
class ConditioningFieldData:

View File

@@ -3,9 +3,10 @@ Initialization file for invokeai.backend.util
"""
from invokeai.backend.util.logging import InvokeAILogger
from invokeai.backend.util.util import Chdir, directory_size
from invokeai.backend.util.util import GIG, Chdir, directory_size
__all__ = [
"GIG",
"directory_size",
"Chdir",
"InvokeAILogger",

View File

@@ -7,6 +7,9 @@ from pathlib import Path
from PIL import Image
# actual size of a gig
GIG = 1073741824
def slugify(value: str, allow_unicode: bool = False) -> str:
"""

View File

@@ -164,10 +164,10 @@
"alpha": "Alpha",
"selected": "Selected",
"tab": "Tab",
"view": "View",
"viewDesc": "Review images in a large gallery view",
"edit": "Edit",
"editDesc": "Edit on the Canvas",
"viewing": "Viewing",
"viewingDesc": "Review images in a large gallery view",
"editing": "Editing",
"editingDesc": "Edit on the Control Layers canvas",
"comparing": "Comparing",
"comparingDesc": "Comparing two images",
"enabled": "Enabled",
@@ -328,13 +328,9 @@
"completedIn": "Completed in",
"batch": "Batch",
"origin": "Origin",
"destination": "Destination",
"upscaling": "Upscaling",
"canvas": "Canvas",
"generation": "Generation",
"workflows": "Workflows",
"other": "Other",
"gallery": "Gallery",
"originCanvas": "Canvas",
"originWorkflows": "Workflows",
"originOther": "Other",
"batchFieldValues": "Batch Field Values",
"item": "Item",
"session": "Session",
@@ -706,8 +702,6 @@
"availableModels": "Available Models",
"baseModel": "Base Model",
"cancel": "Cancel",
"clipEmbed": "CLIP Embed",
"clipVision": "CLIP Vision",
"config": "Config",
"convert": "Convert",
"convertingModelBegin": "Converting Model. Please wait.",
@@ -795,7 +789,6 @@
"settings": "Settings",
"simpleModelPlaceholder": "URL or path to a local file or diffusers folder",
"source": "Source",
"spandrelImageToImage": "Image to Image (Spandrel)",
"starterModels": "Starter Models",
"starterModelsInModelManager": "Starter Models can be found in Model Manager",
"syncModels": "Sync Models",
@@ -804,7 +797,6 @@
"loraTriggerPhrases": "LoRA Trigger Phrases",
"mainModelTriggerPhrases": "Main Model Trigger Phrases",
"typePhraseHere": "Type phrase here",
"t5Encoder": "T5 Encoder",
"upcastAttention": "Upcast Attention",
"uploadImage": "Upload Image",
"urlOrLocalPath": "URL or Local Path",
@@ -1654,14 +1646,6 @@
"storeNotInitialized": "Store is not initialized"
},
"controlLayers": {
"bookmark": "Bookmark for Quick Switch",
"removeBookmark": "Remove Bookmark",
"saveBboxToGallery": "Save Bbox To Gallery",
"savedToGalleryOk": "Saved to Gallery",
"savedToGalleryError": "Error saving to gallery",
"mergeVisible": "Merge Visible",
"mergeVisibleOk": "Merged visible layers",
"mergeVisibleError": "Error merging visible layers",
"clearHistory": "Clear History",
"generateMode": "Generate",
"generateModeDesc": "Create individual images. Generated images are added directly to the gallery.",
@@ -1691,47 +1675,35 @@
"deletePrompt": "Delete Prompt",
"resetRegion": "Reset Region",
"debugLayers": "Debug Layers",
"showHUD": "Show HUD",
"rectangle": "Rectangle",
"maskFill": "Mask Fill",
"addPositivePrompt": "Add $t(common.positivePrompt)",
"addNegativePrompt": "Add $t(common.negativePrompt)",
"addIPAdapter": "Add $t(common.ipAdapter)",
"addRasterLayer": "Add $t(controlLayers.rasterLayer)",
"addControlLayer": "Add $t(controlLayers.controlLayer)",
"addInpaintMask": "Add $t(controlLayers.inpaintMask)",
"addRegionalGuidance": "Add $t(controlLayers.regionalGuidance)",
"regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)",
"raster": "Raster",
"rasterLayer": "Raster Layer",
"controlLayer": "Control Layer",
"inpaintMask": "Inpaint Mask",
"regionalGuidance": "Regional Guidance",
"ipAdapter": "IP Adapter",
"sendToGallery": "Send To Gallery",
"sendToGalleryDesc": "Generations will be sent to the gallery.",
"sendToCanvas": "Send To Canvas",
"sendToCanvasDesc": "Generations will be staged onto the canvas.",
"rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)",
"controlLayer_withCount_one": "$t(controlLayers.controlLayer)",
"inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)",
"regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)",
"ipAdapter_withCount_one": "$t(controlLayers.ipAdapter)",
"rasterLayer_withCount_other": "Raster Layers",
"controlLayer_withCount_other": "Control Layers",
"inpaintMask_withCount_other": "Inpaint Masks",
"regionalGuidance_withCount_other": "Regional Guidance",
"ipAdapter_withCount_other": "IP Adapters",
"rasterLayer_one": "Raster Layer",
"controlLayer_one": "Control Layer",
"inpaintMask_one": "Inpaint Mask",
"regionalGuidance_one": "Regional Guidance",
"ipAdapter_one": "IP Adapter",
"rasterLayer_other": "Raster Layers",
"controlLayer_other": "Control Layers",
"inpaintMask_other": "Inpaint Masks",
"regionalGuidance_other": "Regional Guidance",
"ipAdapter_other": "IP Adapters",
"opacity": "Opacity",
"regionalGuidance_withCount_hidden": "Regional Guidance ({{count}} hidden)",
"controlAdapters_withCount_hidden": "Control Adapters ({{count}} hidden)",
"controlLayers_withCount_hidden": "Control Layers ({{count}} hidden)",
"rasterLayers_withCount_hidden": "Raster Layers ({{count}} hidden)",
"globalIPAdapters_withCount_hidden": "Global IP Adapters ({{count}} hidden)",
"ipAdapters_withCount_hidden": "IP Adapters ({{count}} hidden)",
"inpaintMasks_withCount_hidden": "Inpaint Masks ({{count}} hidden)",
"regionalGuidance_withCount_visible": "Regional Guidance ({{count}})",
"controlAdapters_withCount_visible": "Control Adapters ({{count}})",
"controlLayers_withCount_visible": "Control Layers ({{count}})",
"rasterLayers_withCount_visible": "Raster Layers ({{count}})",
"globalIPAdapters_withCount_visible": "Global IP Adapters ({{count}})",
"ipAdapters_withCount_visible": "IP Adapters ({{count}})",
"inpaintMasks_withCount_visible": "Inpaint Masks ({{count}})",
"globalControlAdapter": "Global $t(controlnet.controlAdapter_one)",
"globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)",
@@ -1744,8 +1716,8 @@
"clearProcessor": "Clear Processor",
"resetProcessor": "Reset Processor to Defaults",
"noLayersAdded": "No Layers Added",
"layer_one": "Layer",
"layer_other": "Layers",
"layers_one": "Layer",
"layers_other": "Layers",
"objects_zero": "empty",
"objects_one": "{{count}} object",
"objects_other": "{{count}} objects",
@@ -1765,7 +1737,6 @@
"flipHorizontal": "Flip Horizontal",
"flipVertical": "Flip Vertical",
"fill": {
"fillColor": "Fill Color",
"fillStyle": "Fill Style",
"solid": "Solid",
"grid": "Grid",
@@ -1781,6 +1752,7 @@
"bbox": "Bbox",
"move": "Move",
"view": "View",
"transform": "Transform",
"colorPicker": "Color Picker"
},
"filter": {
@@ -1790,13 +1762,6 @@
"preview": "Preview",
"apply": "Apply",
"cancel": "Cancel"
},
"transform": {
"transform": "Transform",
"fitToBbox": "Fit to Bbox",
"reset": "Reset",
"apply": "Apply",
"cancel": "Cancel"
}
},
"upscaling": {

View File

@@ -16,7 +16,6 @@ import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicP
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import { activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
import SettingsModal from 'features/system/components/SettingsModal/SettingsModal';
import { configChanged } from 'features/system/store/configSlice';
@@ -44,17 +43,10 @@ interface Props {
action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters';
};
selectedWorkflowId?: string;
selectedStylePresetId?: string;
destination?: TabName;
destination?: TabName | undefined;
}
const App = ({
config = DEFAULT_CONFIG,
selectedImage,
selectedWorkflowId,
selectedStylePresetId,
destination,
}: Props) => {
const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, destination }: Props) => {
const language = useAppSelector(selectLanguage);
const logger = useLogger('system');
const dispatch = useAppDispatch();
@@ -93,12 +85,6 @@ const App = ({
}
}, [selectedWorkflowId, getAndLoadWorkflow]);
useEffect(() => {
if (selectedStylePresetId) {
dispatch(activeStylePresetIdChanged(selectedStylePresetId));
}
}, [dispatch, selectedStylePresetId]);
useEffect(() => {
if (destination) {
dispatch(setActiveTab(destination));

View File

@@ -45,7 +45,6 @@ interface Props extends PropsWithChildren {
action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters';
};
selectedWorkflowId?: string;
selectedStylePresetId?: string;
destination?: TabName;
customStarUi?: CustomStarUi;
socketOptions?: Partial<ManagerOptions & SocketOptions>;
@@ -67,7 +66,6 @@ const InvokeAIUI = ({
queueId,
selectedImage,
selectedWorkflowId,
selectedStylePresetId,
destination,
customStarUi,
socketOptions,
@@ -229,7 +227,6 @@ const InvokeAIUI = ({
config={config}
selectedImage={selectedImage}
selectedWorkflowId={selectedWorkflowId}
selectedStylePresetId={selectedStylePresetId}
destination={destination}
/>
</AppDndContext>

View File

@@ -68,7 +68,7 @@ export const addStagingListeners = (startAppListening: AppStartListening) => {
objects: [imageObject],
};
api.dispatch(rasterLayerAdded({ overrides, isSelected: false }));
api.dispatch(rasterLayerAdded({ overrides, isSelected: true }));
api.dispatch(sessionStagingAreaReset());
},
});

View File

@@ -31,7 +31,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
let didStartStaging = false;
if (!state.canvasSession.isStaging && state.canvasSession.sendToCanvas) {
if (!state.canvasSession.isStaging && state.canvasSession.mode === 'compose') {
dispatch(sessionStartedStaging());
didStartStaging = true;
}
@@ -70,11 +70,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
const { g, noise, posCond } = buildGraphResult.value;
const destination = state.canvasSession.sendToCanvas ? 'canvas' : 'gallery';
const prepareBatchResult = withResult(() =>
prepareLinearUIBatch(state, g, prepend, noise, posCond, 'generation', destination)
);
const prepareBatchResult = withResult(() => prepareLinearUIBatch(state, g, prepend, noise, posCond));
if (isErr(prepareBatchResult)) {
log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch');

View File

@@ -32,7 +32,6 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
workflow: builtWorkflow,
runs: state.params.iterations,
origin: 'workflows',
destination: 'gallery',
},
prepend: action.payload.prepend,
};

View File

@@ -16,7 +16,7 @@ export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening)
const { g, noise, posCond } = await buildMultidiffusionUpscaleGraph(state);
const batchConfig = prepareLinearUIBatch(state, g, prepend, noise, posCond, 'upscaling', 'gallery');
const batchConfig = prepareLinearUIBatch(state, g, prepend, noise, posCond);
const req = dispatch(
queueApi.endpoints.enqueueBatch.initiate(batchConfig, {

View File

@@ -1,104 +0,0 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, IconButton, Tooltip, useToken } from '@invoke-ai/ui-library';
import type { ReactElement, ReactNode } from 'react';
import { memo, useCallback, useMemo } from 'react';
type IconSwitchProps = {
isChecked: boolean;
onChange: (checked: boolean) => void;
iconChecked: ReactElement;
tooltipChecked?: ReactNode;
iconUnchecked: ReactElement;
tooltipUnchecked?: ReactNode;
ariaLabel: string;
};
const getSx = (padding: string | number): SystemStyleObject => ({
transition: 'left 0.1s ease-in-out, transform 0.1s ease-in-out',
'&[data-checked="true"]': {
left: `calc(100% - ${padding})`,
transform: 'translateX(-100%)',
},
'&[data-checked="false"]': {
left: padding,
transform: 'translateX(0)',
},
});
export const IconSwitch = memo(
({
isChecked,
onChange,
iconChecked,
tooltipChecked,
iconUnchecked,
tooltipUnchecked,
ariaLabel,
}: IconSwitchProps) => {
const onUncheck = useCallback(() => {
onChange(false);
}, [onChange]);
const onCheck = useCallback(() => {
onChange(true);
}, [onChange]);
const gap = useToken('space', 1.5);
const sx = useMemo(() => getSx(gap), [gap]);
return (
<Flex
position="relative"
bg="base.800"
borderRadius="base"
alignItems="center"
justifyContent="center"
h="full"
p={gap}
gap={gap}
>
<Box
position="absolute"
borderRadius="base"
bg="invokeBlue.400"
w={12}
top={gap}
bottom={gap}
data-checked={isChecked}
sx={sx}
/>
<Tooltip hasArrow label={tooltipUnchecked}>
<IconButton
size="sm"
fontSize={16}
icon={iconUnchecked}
onClick={onUncheck}
variant={!isChecked ? 'solid' : 'ghost'}
colorScheme={!isChecked ? 'invokeBlue' : 'base'}
aria-label={ariaLabel}
data-checked={!isChecked}
w={12}
alignSelf="stretch"
h="auto"
/>
</Tooltip>
<Tooltip hasArrow label={tooltipChecked}>
<IconButton
size="sm"
fontSize={16}
icon={iconChecked}
onClick={onCheck}
variant={isChecked ? 'solid' : 'ghost'}
colorScheme={isChecked ? 'invokeBlue' : 'base'}
aria-label={ariaLabel}
data-checked={isChecked}
w={12}
alignSelf="stretch"
h="auto"
/>
</Tooltip>
</Flex>
);
}
);
IconSwitch.displayName = 'IconSwitch';

View File

@@ -1,74 +1,52 @@
import { useStore } from '@nanostores/react';
import type { WritableAtom } from 'nanostores';
import { atom } from 'nanostores';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
type UseBoolean = {
isTrue: boolean;
setTrue: () => void;
setFalse: () => void;
set: (value: boolean) => void;
toggle: () => void;
};
/**
* Creates a hook to manage a boolean state. The boolean is stored in a nanostores atom.
* Returns a tuple containing the hook and the atom. Use this for global boolean state.
* @param initialValue Initial value of the boolean
*/
export const buildUseBoolean = (initialValue: boolean): [() => UseBoolean, WritableAtom<boolean>] => {
const $boolean = atom(initialValue);
const setTrue = () => {
$boolean.set(true);
};
const setFalse = () => {
$boolean.set(false);
};
const set = (value: boolean) => {
$boolean.set(value);
};
const toggle = () => {
$boolean.set(!$boolean.get());
};
const useBoolean = () => {
const isTrue = useStore($boolean);
return {
isTrue,
setTrue,
setFalse,
set,
toggle,
};
};
return [useBoolean, $boolean] as const;
};
/**
* Hook to manage a boolean state. Use this for a local boolean state.
* @param initialValue Initial value of the boolean
*/
export const useBoolean = (initialValue: boolean) => {
const [isTrue, set] = useState(initialValue);
const setTrue = useCallback(() => set(true), []);
const setFalse = useCallback(() => set(false), []);
const toggle = useCallback(() => set((v) => !v), []);
const setTrue = useCallback(() => {
set(true);
}, [set]);
const setFalse = useCallback(() => {
set(false);
}, [set]);
const toggle = useCallback(() => {
set((val) => !val);
}, [set]);
const api = useMemo(
() => ({
isTrue,
set,
setTrue,
setFalse,
toggle,
}),
[isTrue, set, setTrue, setFalse, toggle]
);
return {
isTrue,
setTrue,
setFalse,
set,
toggle,
return api;
};
export const buildUseBoolean = ($boolean: WritableAtom<boolean>) => {
return () => {
const setTrue = useCallback(() => {
$boolean.set(true);
}, []);
const setFalse = useCallback(() => {
$boolean.set(false);
}, []);
const set = useCallback((value: boolean) => {
$boolean.set(value);
}, []);
const toggle = useCallback(() => {
$boolean.set(!$boolean.get());
}, []);
const api = useMemo(
() => ({
setTrue,
setFalse,
set,
toggle,
$boolean,
}),
[set, setFalse, setTrue, toggle]
);
return api;
};
};

View File

@@ -1,7 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { addScope, removeScope, setScopes } from 'common/hooks/interactionScopes';
import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
import { useQueueFront } from 'features/queue/hooks/useQueueFront';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';

View File

@@ -128,7 +128,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.controlLayers.entities
.filter((controlLayer) => controlLayer.isEnabled)
.forEach((controlLayer, i) => {
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY['control_layer']);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
@@ -158,7 +158,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.ipAdapters.entities
.filter((entity) => entity.isEnabled)
.forEach((entity, i) => {
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
@@ -186,7 +186,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.regions.entities
.filter((entity) => entity.isEnabled)
.forEach((entity, i) => {
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
@@ -223,7 +223,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
canvas.rasterLayers.entities
.filter((entity) => entity.isEnabled)
.forEach((entity, i) => {
const layerLiteral = i18n.t('controlLayers.layer_one');
const layerLiteral = i18n.t('controlLayers.layers_one');
const layerNumber = i + 1;
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;

View File

@@ -31,22 +31,22 @@ export const CanvasAddEntityButtons = memo(() => {
}, [dispatch]);
return (
<Flex flexDir="column" w="full" h="full" alignItems="center">
<ButtonGroup position="relative" orientation="vertical" isAttached={false} top="20%">
<Flex flexDir="column" w="full" h="full" alignItems="center" justifyContent="center">
<ButtonGroup orientation="vertical" isAttached={false}>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addInpaintMask}>
{t('controlLayers.inpaintMask')}
{t('controlLayers.inpaintMask', { count: 1 })}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addRegionalGuidance}>
{t('controlLayers.regionalGuidance')}
{t('controlLayers.regionalGuidance', { count: 1 })}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addRasterLayer}>
{t('controlLayers.rasterLayer')}
{t('controlLayers.rasterLayer', { count: 1 })}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addControlLayer}>
{t('controlLayers.controlLayer')}
{t('controlLayers.controlLayer', { count: 1 })}
</Button>
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addIPAdapter}>
{t('controlLayers.globalIPAdapter')}
{t('controlLayers.ipAdapter', { count: 1 })}
</Button>
</ButtonGroup>
</Flex>

View File

@@ -1,6 +1,5 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
import {
controlLayerAdded,
inpaintMaskAdded,
@@ -15,7 +14,6 @@ import { PiPlusBold } from 'react-icons/pi';
export const CanvasEntityListMenuItems = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const defaultIPAdapter = useDefaultIPAdapter();
const addInpaintMask = useCallback(() => {
dispatch(inpaintMaskAdded({ isSelected: true }));
}, [dispatch]);
@@ -29,26 +27,25 @@ export const CanvasEntityListMenuItems = memo(() => {
dispatch(controlLayerAdded({ isSelected: true }));
}, [dispatch]);
const addIPAdapter = useCallback(() => {
const overrides = { ipAdapter: defaultIPAdapter };
dispatch(ipaAdded({ isSelected: true, overrides }));
}, [defaultIPAdapter, dispatch]);
dispatch(ipaAdded({ isSelected: true }));
}, [dispatch]);
return (
<>
<MenuItem icon={<PiPlusBold />} onClick={addInpaintMask}>
{t('controlLayers.inpaintMask')}
{t('controlLayers.inpaintMask', { count: 1 })}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addRegionalGuidance}>
{t('controlLayers.regionalGuidance')}
{t('controlLayers.regionalGuidance', { count: 1 })}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addRasterLayer}>
{t('controlLayers.rasterLayer')}
{t('controlLayers.rasterLayer', { count: 1 })}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addControlLayer}>
{t('controlLayers.controlLayer')}
{t('controlLayers.controlLayer', { count: 1 })}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addIPAdapter}>
{t('controlLayers.globalIPAdapter')}
{t('controlLayers.ipAdapter', { count: 1 })}
</MenuItem>
</>
);

View File

@@ -37,11 +37,11 @@ function formatPct(v: number | string) {
return `${round(Number(v), 2).toLocaleString()}%`;
}
function mapSliderValueToRawValue(value: number) {
function mapSliderValueToOpacity(value: number) {
return value / 100;
}
function mapRawValueToSliderValue(opacity: number) {
function mapOpacityToSliderValue(opacity: number) {
return opacity * 100;
}
@@ -50,14 +50,14 @@ function formatSliderValue(value: number) {
}
const marks = [
mapRawValueToSliderValue(0),
mapRawValueToSliderValue(0.25),
mapRawValueToSliderValue(0.5),
mapRawValueToSliderValue(0.75),
mapRawValueToSliderValue(1),
mapOpacityToSliderValue(0),
mapOpacityToSliderValue(0.25),
mapOpacityToSliderValue(0.5),
mapOpacityToSliderValue(0.75),
mapOpacityToSliderValue(1),
];
const sliderDefaultValue = mapRawValueToSliderValue(1);
const sliderDefaultValue = mapOpacityToSliderValue(1);
const snapCandidates = marks.slice(1, marks.length - 1);
@@ -95,7 +95,7 @@ export const SelectedEntityOpacity = memo(() => {
if (!$shift.get()) {
snappedOpacity = snapToNearest(opacity, snapCandidates, 2);
}
const mappedOpacity = mapSliderValueToRawValue(snappedOpacity);
const mappedOpacity = mapSliderValueToOpacity(snappedOpacity);
dispatch(entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: mappedOpacity }));
},
@@ -157,7 +157,7 @@ export const SelectedEntityOpacity = memo(() => {
clampValueOnBlur={false}
variant="outline"
>
<NumberInputField paddingInlineEnd={7} _focusVisible={{ zIndex: 0 }} />
<NumberInputField paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"

View File

@@ -0,0 +1,29 @@
import { Button, ButtonGroup } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSessionSlice, sessionModeChanged } from 'features/controlLayers/store/canvasSessionSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const selectCanvasMode = createSelector(selectCanvasSessionSlice, (canvasSession) => canvasSession.mode);
export const CanvasModeSwitcher = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const mode = useAppSelector(selectCanvasMode);
const onClickGenerate = useCallback(() => dispatch(sessionModeChanged({ mode: 'generate' })), [dispatch]);
const onClickCompose = useCallback(() => dispatch(sessionModeChanged({ mode: 'compose' })), [dispatch]);
return (
<ButtonGroup variant="outline">
<Button onClick={onClickGenerate} colorScheme={mode === 'generate' ? 'invokeBlue' : 'base'}>
{t('controlLayers.generateMode')}
</Button>
<Button onClick={onClickCompose} colorScheme={mode === 'compose' ? 'invokeBlue' : 'base'}>
{t('controlLayers.composeMode')}
</Button>
</ButtonGroup>
);
});
CanvasModeSwitcher.displayName = 'CanvasModeSwitcher';

View File

@@ -9,7 +9,6 @@ import { memo } from 'react';
export const CanvasPanelContent = memo(() => {
const hasEntities = useAppSelector(selectHasEntities);
return (
<CanvasManagerProviderGate>
<Flex flexDir="column" gap={2} w="full" h="full">

View File

@@ -1,4 +1,4 @@
import { $alt, IconButton } from '@invoke-ai/ui-library';
import { $shift, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
@@ -7,7 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
export const CanvasToolbarResetViewButton = memo(() => {
export const CanvasResetViewButton = memo(() => {
const { t } = useTranslation();
const canvasManager = useStore($canvasManager);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
@@ -27,7 +27,7 @@ export const CanvasToolbarResetViewButton = memo(() => {
}, [canvasManager]);
const onReset = useCallback(() => {
if ($alt.get()) {
if ($shift.get()) {
resetView();
} else {
resetZoom();
@@ -35,7 +35,7 @@ export const CanvasToolbarResetViewButton = memo(() => {
}, [resetView, resetZoom]);
useHotkeys('r', resetView, { enabled: isCanvasActive }, [isCanvasActive]);
useHotkeys('alt+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]);
useHotkeys('shift+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]);
return (
<IconButton
@@ -48,4 +48,4 @@ export const CanvasToolbarResetViewButton = memo(() => {
);
});
CanvasToolbarResetViewButton.displayName = 'CanvasToolbarResetViewButton';
CanvasResetViewButton.displayName = 'CanvasResetViewButton';

View File

@@ -15,8 +15,9 @@ import {
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/controlLayers/konva/constants';
import { snapToNearest } from 'features/controlLayers/konva/util';
import { round } from 'lodash-es';
import { clamp, round } from 'lodash-es';
import { computed } from 'nanostores';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
@@ -31,7 +32,7 @@ function formatPct(v: number | string) {
return `${round(Number(v), 2).toLocaleString()}%`;
}
function mapSliderValueToRawValue(value: number) {
function mapSliderValueToScale(value: number) {
if (value <= 40) {
// 0 to 40 -> 10% to 100%
return 10 + (90 * value) / 40;
@@ -44,58 +45,64 @@ function mapSliderValueToRawValue(value: number) {
}
}
function mapRawValueToSliderValue(value: number) {
if (value <= 100) {
return ((value - 10) * 40) / 90;
} else if (value <= 500) {
return 40 + ((value - 100) * 30) / 400;
function mapScaleToSliderValue(scale: number) {
if (scale <= 100) {
return ((scale - 10) * 40) / 90;
} else if (scale <= 500) {
return 40 + ((scale - 100) * 30) / 400;
} else {
return 70 + ((value - 500) * 30) / 1500;
return 70 + ((scale - 500) * 30) / 1500;
}
}
function formatSliderValue(value: number) {
return String(mapSliderValueToRawValue(value));
return String(mapSliderValueToScale(value));
}
const marks = [
mapRawValueToSliderValue(10),
mapRawValueToSliderValue(50),
mapRawValueToSliderValue(100),
mapRawValueToSliderValue(500),
mapRawValueToSliderValue(2000),
mapScaleToSliderValue(10),
mapScaleToSliderValue(50),
mapScaleToSliderValue(100),
mapScaleToSliderValue(500),
mapScaleToSliderValue(2000),
];
const sliderDefaultValue = mapRawValueToSliderValue(100);
const sliderDefaultValue = mapScaleToSliderValue(100);
const snapCandidates = marks.slice(1, marks.length - 1);
export const CanvasToolbarScale = memo(() => {
export const CanvasScale = memo(() => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const scale = useStore(computed(canvasManager.stage.$stageAttrs, (attrs) => attrs.scale));
const scale = useStore(computed(canvasManager.stateApi.$stageAttrs, (attrs) => attrs.scale));
const [localScale, setLocalScale] = useState(scale * 100);
const onChangeSlider = useCallback(
(scale: number) => {
if (!canvasManager) {
return;
}
let snappedScale = scale;
// Do not snap if shift key is held
if (!$shift.get()) {
snappedScale = snapToNearest(scale, snapCandidates, 2);
}
const mappedScale = mapSliderValueToRawValue(snappedScale);
const mappedScale = mapSliderValueToScale(snappedScale);
canvasManager.stage.setScale(mappedScale / 100);
},
[canvasManager]
);
const onBlur = useCallback(() => {
if (!canvasManager) {
return;
}
if (isNaN(Number(localScale))) {
canvasManager.stage.setScale(1);
setLocalScale(100);
return;
}
canvasManager.stage.setScale(localScale / 100);
canvasManager.stage.setScale(clamp(localScale / 100, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE));
}, [canvasManager, localScale]);
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
@@ -123,8 +130,8 @@ export const CanvasToolbarScale = memo(() => {
<NumberInput
display="flex"
alignItems="center"
min={canvasManager.stage.config.MIN_SCALE * 100}
max={canvasManager.stage.config.MAX_SCALE * 100}
min={MIN_CANVAS_SCALE * 100}
max={MAX_CANVAS_SCALE * 100}
value={localScale}
onChange={onChangeNumberInput}
onBlur={onBlur}
@@ -155,7 +162,7 @@ export const CanvasToolbarScale = memo(() => {
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localScale)}
value={mapScaleToSliderValue(localScale)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
@@ -168,4 +175,4 @@ export const CanvasToolbarScale = memo(() => {
);
});
CanvasToolbarScale.displayName = 'CanvasToolbarScale';
CanvasScale.displayName = 'CanvasScale';

View File

@@ -1,59 +0,0 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IconSwitch } from 'common/components/IconSwitch';
import { selectIsComposing, sessionSendToCanvasChanged } from 'features/controlLayers/store/canvasSessionSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold, PiPaintBrushBold } from 'react-icons/pi';
const TooltipSendToGallery = memo(() => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{t('controlLayers.sendToGallery')}</Text>
<Text fontWeight="normal">{t('controlLayers.sendToGalleryDesc')}</Text>
</Flex>
);
});
TooltipSendToGallery.displayName = 'TooltipSendToGallery';
const TooltipSendToCanvas = memo(() => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text fontWeight="semibold">{t('controlLayers.sendToCanvas')}</Text>
<Text fontWeight="normal">{t('controlLayers.sendToCanvasDesc')}</Text>
</Flex>
);
});
TooltipSendToCanvas.displayName = 'TooltipSendToCanvas';
export const CanvasSendToToggle = memo(() => {
const dispatch = useAppDispatch();
const isComposing = useAppSelector(selectIsComposing);
const onChange = useCallback(
(isChecked: boolean) => {
dispatch(sessionSendToCanvasChanged(isChecked));
},
[dispatch]
);
return (
<IconSwitch
isChecked={isComposing}
onChange={onChange}
iconUnchecked={<PiImageBold />}
tooltipUnchecked={<TooltipSendToGallery />}
iconChecked={<PiPaintBrushBold />}
tooltipChecked={<TooltipSendToCanvas />}
ariaLabel="Toggle canvas mode"
/>
);
});
CanvasSendToToggle.displayName = 'CanvasSendToToggle';

View File

@@ -1,7 +1,8 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
@@ -28,7 +29,8 @@ export const ControlLayer = memo(({ id }: Props) => {
<CanvasEntityEditableTitle />
<Spacer />
<ControlLayerBadges />
<CanvasEntityHeaderCommonActions />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
</CanvasEntityHeader>
<CanvasEntitySettingsWrapper>
<ControlLayerControlAdapter />

View File

@@ -1,6 +1,6 @@
import { Flex } from '@invoke-ai/ui-library';
import type { Meta, StoryObj } from '@storybook/react';
import { CanvasEditor } from 'features/controlLayers/components/CanvasEditor';
import { CanvasEditor } from 'features/controlLayers/components/ControlLayersEditor';
const meta: Meta<typeof CanvasEditor> = {
title: 'Feature/ControlLayers',

View File

@@ -2,12 +2,12 @@
import { Flex } from '@invoke-ai/ui-library';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
import { ControlLayersToolbar } from 'features/controlLayers/components/ControlLayersToolbar';
import { Filter } from 'features/controlLayers/components/Filters/Filter';
import { StageComponent } from 'features/controlLayers/components/StageComponent';
import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
import { Transform } from 'features/controlLayers/components/Transform/Transform';
import { Transform } from 'features/controlLayers/components/Transform';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useRef } from 'react';
@@ -28,7 +28,7 @@ export const CanvasEditor = memo(() => {
alignItems="center"
justifyContent="center"
>
<CanvasToolbar />
<ControlLayersToolbar />
<StageComponent />
<Flex position="absolute" bottom={8} gap={2} align="center" justify="center">
<CanvasManagerProviderGate>

View File

@@ -0,0 +1,38 @@
/* eslint-disable i18next/no-literal-string */
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { CanvasModeSwitcher } from 'features/controlLayers/components/CanvasModeSwitcher';
import { CanvasResetViewButton } from 'features/controlLayers/components/CanvasResetViewButton';
import { CanvasScale } from 'features/controlLayers/components/CanvasScale';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { ViewerToggleMenu } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
import { memo } from 'react';
export const ControlLayersToolbar = memo(() => {
return (
<CanvasManagerProviderGate>
<Flex w="full" gap={2} alignItems="center">
<ToggleProgressButton />
<ToolChooser />
<Spacer />
<ToolSettings />
<Spacer />
<CanvasScale />
<CanvasResetViewButton />
<Spacer />
<ToolFillColorPicker />
<CanvasModeSwitcher />
<UndoRedoButtonGroup />
<CanvasSettingsPopover />
<ViewerToggleMenu />
</Flex>
</CanvasManagerProviderGate>
);
});
ControlLayersToolbar.displayName = 'ControlLayersToolbar';

View File

@@ -1,27 +1,53 @@
import { Grid, GridItem, Text } from '@invoke-ai/ui-library';
import { Box, Flex, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { round } from 'lodash-es';
import { memo } from 'react';
const selectBbox = createSelector(selectCanvasSlice, (canvas) => canvas.bbox);
export const HeadsUpDisplay = memo(() => {
const canvasManager = useCanvasManager();
const stageAttrs = useStore(canvasManager.stateApi.$stageAttrs);
const cursorPos = useStore(canvasManager.stateApi.$lastCursorPos);
const isDrawing = useStore(canvasManager.stateApi.$isDrawing);
const isMouseDown = useStore(canvasManager.stateApi.$isMouseDown);
const lastMouseDownPos = useStore(canvasManager.stateApi.$lastMouseDownPos);
const lastAddedPoint = useStore(canvasManager.stateApi.$lastAddedPoint);
const bbox = useAppSelector(selectBbox);
return (
<Grid
bg="base.900"
borderBottomEndRadius="base"
p={2}
gap={2}
borderRadius="base"
templateColumns="auto auto"
opacity={0.6}
>
<HUDItem label="BBox" value={`${bbox.rect.width}×${bbox.rect.height} px`} />
<HUDItem label="Scaled BBox" value={`${bbox.scaledSize.width}×${bbox.scaledSize.height} px`} />
</Grid>
<Flex flexDir="column" bg="blackAlpha.400" borderBottomEndRadius="base" p={2} minW={64} gap={2}>
<HUDItem label="Zoom" value={`${round(stageAttrs.scale * 100, 2)}%`} />
<HUDItem label="Stage Pos" value={`${round(stageAttrs.x, 3)}, ${round(stageAttrs.y, 3)}`} />
<HUDItem
label="Stage Size"
value={`${round(stageAttrs.width / stageAttrs.scale, 2)}×${round(stageAttrs.height / stageAttrs.scale, 2)} px`}
/>
<HUDItem label="BBox Size" value={`${bbox.rect.width}×${bbox.rect.height} px`} />
<HUDItem label="BBox Position" value={`${bbox.rect.x}, ${bbox.rect.y}`} />
<HUDItem label="BBox Width % 8" value={round(bbox.rect.width % 8, 2)} />
<HUDItem label="BBox Height % 8" value={round(bbox.rect.height % 8, 2)} />
<HUDItem label="BBox X % 8" value={round(bbox.rect.x % 8, 2)} />
<HUDItem label="BBox Y % 8" value={round(bbox.rect.y % 8, 2)} />
<HUDItem
label="Cursor Position"
value={cursorPos ? `${round(cursorPos.x, 2)}, ${round(cursorPos.y, 2)}` : '?, ?'}
/>
<HUDItem label="Is Drawing" value={isDrawing ? 'True' : 'False'} />
<HUDItem label="Is Mouse Down" value={isMouseDown ? 'True' : 'False'} />
<HUDItem
label="Last Mouse Down Pos"
value={lastMouseDownPos ? `${round(lastMouseDownPos.x, 2)}, ${round(lastMouseDownPos.y, 2)}` : '?, ?'}
/>
<HUDItem
label="Last Added Point"
value={lastAddedPoint ? `${round(lastAddedPoint.x, 2)}, ${round(lastAddedPoint.y, 2)}` : '?, ?'}
/>
</Flex>
);
});
@@ -29,14 +55,12 @@ HeadsUpDisplay.displayName = 'HeadsUpDisplay';
const HUDItem = memo(({ label, value }: { label: string; value: string | number }) => {
return (
<>
<GridItem>
<Text textAlign="end">{label}: </Text>
</GridItem>
<GridItem fontWeight="semibold">
<Text>{value}</Text>
</GridItem>
</>
<Box display="inline-block" lineHeight={1}>
<Text as="span">{label}: </Text>
<Text as="span" fontWeight="semibold">
{value}
</Text>
</Box>
);
});

View File

@@ -1,7 +1,7 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
@@ -18,10 +18,10 @@ export const IPAdapter = memo(({ id }: Props) => {
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader ps={4} py={5}>
<CanvasEntityHeader ps={4}>
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
<CanvasEntityEnabledToggle />
</CanvasEntityHeader>
<IPAdapterSettings />
</CanvasEntityContainer>

View File

@@ -1,7 +1,8 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { EntityMaskAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
@@ -24,7 +25,8 @@ export const InpaintMask = memo(({ id }: Props) => {
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
</CanvasEntityHeader>
</CanvasEntityContainer>
</EntityMaskAdapterGate>

View File

@@ -1,7 +1,8 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { EntityLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
@@ -24,7 +25,8 @@ export const RasterLayer = memo(({ id }: Props) => {
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
</CanvasEntityHeader>
</CanvasEntityContainer>
</EntityLayerAdapterGate>

View File

@@ -1,7 +1,8 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { RegionalGuidanceBadges } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges';
@@ -27,7 +28,8 @@ export const RegionalGuidance = memo(({ id }: Props) => {
<CanvasEntityEditableTitle />
<Spacer />
<RegionalGuidanceBadges />
<CanvasEntityHeaderCommonActions />
<CanvasEntityIsLockedToggle />
<CanvasEntityEnabledToggle />
</CanvasEntityHeader>
<RegionalGuidanceSettings />
</CanvasEntityContainer>

View File

@@ -18,7 +18,6 @@ import { CanvasSettingsInvertScrollCheckbox } from 'features/controlLayers/compo
import { CanvasSettingsLogDebugInfoButton } from 'features/controlLayers/components/Settings/CanvasSettingsLogDebugInfo';
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
import { CanvasSettingsResetButton } from 'features/controlLayers/components/Settings/CanvasSettingsResetButton';
import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiSettings4Fill } from 'react-icons/ri';
@@ -38,7 +37,6 @@ export const CanvasSettingsPopover = memo(() => {
<CanvasSettingsInvertScrollCheckbox />
<CanvasSettingsClipToBboxCheckbox />
<CanvasSettingsDynamicGridSwitch />
<CanvasSettingsShowHUDSwitch />
<CanvasSettingsResetButton />
<DebugSettings />
</Flex>

View File

@@ -1,28 +0,0 @@
import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSettingsSlice, settingsShowHUDToggled } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);
export const CanvasSettingsShowHUDSwitch = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const showHUD = useAppSelector(selectShowHUD);
const onChange = useCallback(() => {
dispatch(settingsShowHUDToggled());
}, [dispatch]);
return (
<FormControl>
<FormLabel m={0} flexGrow={1}>
{t('controlLayers.showHUD')}
</FormLabel>
<Switch size="sm" isChecked={showHUD} onChange={onChange} />
</FormControl>
);
});
CanvasSettingsShowHUDSwitch.displayName = 'CanvasSettingsShowHUDSwitch';

View File

@@ -7,19 +7,21 @@ import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import Konva from 'konva';
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
import { v4 as uuidv4 } from 'uuid';
const log = logger('canvas');
const showHud = false;
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
Konva.showWarnings = false;
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null) => {
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null, asPreview: boolean) => {
const store = useAppStore();
const socket = useStore($socket);
const dpr = useDevicePixelRatio({ round: false });
@@ -40,25 +42,28 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null)
const manager = new CanvasManager(stage, container, store, socket);
manager.initialize();
return manager.destroy;
}, [container, socket, stage, store]);
}, [asPreview, container, socket, stage, store]);
useLayoutEffect(() => {
Konva.pixelRatio = dpr;
}, [dpr]);
};
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.dynamicGrid);
const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);
type Props = {
asPreview?: boolean;
};
export const StageComponent = memo(() => {
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.dynamicGrid);
export const StageComponent = memo(({ asPreview = false }: Props) => {
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);
const [stage] = useState(
() =>
new Konva.Stage({
id: getPrefixedId('konva_stage'),
id: uuidv4(),
container: document.createElement('div'),
listening: !asPreview,
})
);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
@@ -67,7 +72,7 @@ export const StageComponent = memo(() => {
setContainer(el);
}, []);
useStageRenderer(stage, container);
useStageRenderer(stage, container, asPreview);
useEffect(
() => () => {
@@ -82,7 +87,7 @@ export const StageComponent = memo(() => {
<Flex
position="absolute"
borderRadius="base"
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
bgImage={TRANSPARENCY_CHECKER_PATTERN}
top={0}
right={0}
bottom={0}
@@ -101,9 +106,9 @@ export const StageComponent = memo(() => {
overflow="hidden"
data-testid="control-layers-canvas"
/>
{showHUD && (
<Flex position="absolute" top={1} insetInlineStart={1} pointerEvents="none">
<HeadsUpDisplay />
{!asPreview && (
<Flex position="absolute" top={0} insetInlineStart={0} pointerEvents="none">
{showHud && <HeadsUpDisplay />}
</Flex>
)}
</Flex>

View File

@@ -45,7 +45,7 @@ export const StagingAreaToolbar = memo(() => {
const index = useAppSelector(selectStagedImageIndex);
const selectedImage = useAppSelector(selectSelectedImage);
const imageCount = useAppSelector(selectImageCount);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const shouldShowStagedImage = useStore(canvasManager.stateApi.$shouldShowStagedImage);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const [changeIsImageIntermediate] = useChangeImageIsIntermediateMutation();
useScopeOnMount('stagingArea');
@@ -83,8 +83,8 @@ export const StagingAreaToolbar = memo(() => {
}, [dispatch]);
const onToggleShouldShowStagedImage = useCallback(() => {
canvasManager.stagingArea.$shouldShowStagedImage.set(!shouldShowStagedImage);
}, [canvasManager.stagingArea.$shouldShowStagedImage, shouldShowStagedImage]);
canvasManager.stateApi.$shouldShowStagedImage.set(!shouldShowStagedImage);
}, [canvasManager.stateApi.$shouldShowStagedImage, shouldShowStagedImage]);
const onSaveStagingImage = useCallback(() => {
if (!selectedImage) {
@@ -98,9 +98,9 @@ export const StagingAreaToolbar = memo(() => {
onPrev,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
enabled: isCanvasActive,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
[isCanvasActive]
);
useHotkeys(
@@ -108,9 +108,9 @@ export const StagingAreaToolbar = memo(() => {
onNext,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
enabled: isCanvasActive,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
[isCanvasActive]
);
useHotkeys(
@@ -118,9 +118,9 @@ export const StagingAreaToolbar = memo(() => {
onAccept,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
enabled: isCanvasActive,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
[isCanvasActive]
);
const counterText = useMemo(() => {

View File

@@ -20,12 +20,12 @@ export const ToolBboxButton = memo(() => {
return isTransforming || isFiltering || isStaging;
}, [isFiltering, isStaging, isTransforming]);
useHotkeys('c', selectBbox, { enabled: !isDisabled || isSelected }, [selectBbox, isSelected, isDisabled]);
useHotkeys('q', selectBbox, { enabled: !isDisabled || isSelected }, [selectBbox, isSelected, isDisabled]);
return (
<IconButton
aria-label={`${t('controlLayers.tool.bbox')} (C)`}
tooltip={`${t('controlLayers.tool.bbox')} (C)`}
aria-label={`${t('controlLayers.tool.bbox')} (Q)`}
tooltip={`${t('controlLayers.tool.bbox')} (Q)`}
icon={<PiBoundingBoxBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"

View File

@@ -1,12 +1,9 @@
import {
CompositeNumberInput,
CompositeSlider,
FormControl,
FormLabel,
IconButton,
NumberInput,
NumberInputField,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
@@ -14,172 +11,47 @@ import {
} from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { brushWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
import { clamp } from 'lodash-es';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
const selectBrushWidth = createSelector(selectToolSlice, (tool) => tool.brush.width);
const marks = [0, 100, 200, 300];
const formatPx = (v: number | string) => `${v} px`;
function mapSliderValueToRawValue(value: number) {
if (value <= 40) {
// 0 to 40 on the slider -> 1px to 50px
return 1 + (49 * value) / 40;
} else if (value <= 70) {
// 40 to 70 on the slider -> 50px to 200px
return 50 + (150 * (value - 40)) / 30;
} else {
// 70 to 100 on the slider -> 200px to 600px
return 200 + (400 * (value - 70)) / 30;
}
}
function mapRawValueToSliderValue(value: number) {
if (value <= 50) {
// 1px to 50px -> 0 to 40 on the slider
return ((value - 1) * 40) / 49;
} else if (value <= 200) {
// 50px to 200px -> 40 to 70 on the slider
return 40 + ((value - 50) * 30) / 150;
} else {
// 200px to 600px -> 70 to 100 on the slider
return 70 + ((value - 200) * 30) / 400;
}
}
function formatSliderValue(value: number) {
return `${String(mapSliderValueToRawValue(value))} px`;
}
const marks = [
mapRawValueToSliderValue(1),
mapRawValueToSliderValue(50),
mapRawValueToSliderValue(200),
mapRawValueToSliderValue(600),
];
const sliderDefaultValue = mapRawValueToSliderValue(50);
const selectBrushWidth = createSelector(selectToolSlice, (tool) => tool.brush.width);
export const ToolBrushWidth = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isSelected = useToolIsSelected('brush');
const width = useAppSelector(selectBrushWidth);
const [localValue, setLocalValue] = useState(width);
const onChange = useCallback(
(v: number) => {
dispatch(brushWidthChanged(clamp(Math.round(v), 1, 600)));
dispatch(brushWidthChanged(Math.round(v)));
},
[dispatch]
);
const increment = useCallback(() => {
let newWidth = Math.round(width * 1.15);
if (newWidth === width) {
newWidth += 1;
}
onChange(newWidth);
}, [onChange, width]);
const decrement = useCallback(() => {
let newWidth = Math.round(width * 0.85);
if (newWidth === width) {
newWidth -= 1;
}
onChange(newWidth);
}, [onChange, width]);
const onChangeSlider = useCallback(
(value: number) => {
onChange(mapSliderValueToRawValue(value));
},
[onChange]
);
const onBlur = useCallback(() => {
if (isNaN(Number(localValue))) {
onChange(50);
setLocalValue(50);
} else {
onChange(localValue);
}
}, [localValue, onChange]);
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
setLocalValue(valueAsNumber);
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onBlur();
}
},
[onBlur]
);
useEffect(() => {
setLocalValue(width);
}, [width]);
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
return (
<Popover>
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<PopoverAnchor>
<NumberInput
display="flex"
alignItems="center"
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<Popover isLazy>
<PopoverTrigger>
<CompositeNumberInput
min={1}
max={600}
value={localValue}
onChange={onChangeNumberInput}
onBlur={onBlur}
w="76px"
format={formatPx}
defaultValue={50}
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"
icon={<PiCaretDownBold />}
size="sm"
variant="link"
position="absolute"
insetInlineEnd={0}
h="full"
/>
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
value={width}
onChange={onChange}
w={24}
format={formatPx}
/>
</PopoverBody>
</PopoverContent>
</Popover>
</PopoverTrigger>
<PopoverContent w={200} py={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
</PopoverBody>
</PopoverContent>
</Popover>
</FormControl>
);
});

View File

@@ -4,11 +4,16 @@ import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrus
import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/ToolColorPickerButton';
import { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton';
import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton';
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
import { ToolEraserButton } from './ToolEraserButton';
import { ToolViewButton } from './ToolViewButton';
export const ToolChooser: React.FC = () => {
useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey();
return (
<>
<ButtonGroup isAttached>

View File

@@ -1,12 +1,9 @@
import {
CompositeNumberInput,
CompositeSlider,
FormControl,
FormLabel,
IconButton,
NumberInput,
NumberInputField,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
@@ -14,172 +11,47 @@ import {
} from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { eraserWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
import { clamp } from 'lodash-es';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
const selectEraserWidth = createSelector(selectToolSlice, (tool) => tool.eraser.width);
const marks = [0, 100, 200, 300];
const formatPx = (v: number | string) => `${v} px`;
function mapSliderValueToRawValue(value: number) {
if (value <= 40) {
// 0 to 40 on the slider -> 1px to 50px
return 1 + (49 * value) / 40;
} else if (value <= 70) {
// 40 to 70 on the slider -> 50px to 200px
return 50 + (150 * (value - 40)) / 30;
} else {
// 70 to 100 on the slider -> 200px to 600px
return 200 + (400 * (value - 70)) / 30;
}
}
function mapRawValueToSliderValue(value: number) {
if (value <= 50) {
// 1px to 50px -> 0 to 40 on the slider
return ((value - 1) * 40) / 49;
} else if (value <= 200) {
// 50px to 200px -> 40 to 70 on the slider
return 40 + ((value - 50) * 30) / 150;
} else {
// 200px to 600px -> 70 to 100 on the slider
return 70 + ((value - 200) * 30) / 400;
}
}
function formatSliderValue(value: number) {
return `${String(mapSliderValueToRawValue(value))} px`;
}
const marks = [
mapRawValueToSliderValue(1),
mapRawValueToSliderValue(50),
mapRawValueToSliderValue(200),
mapRawValueToSliderValue(600),
];
const sliderDefaultValue = mapRawValueToSliderValue(50);
const selectEraserWidth = createSelector(selectToolSlice, (tool) => tool.eraser.width);
export const ToolEraserWidth = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isSelected = useToolIsSelected('eraser');
const width = useAppSelector(selectEraserWidth);
const [localValue, setLocalValue] = useState(width);
const onChange = useCallback(
(v: number) => {
dispatch(eraserWidthChanged(clamp(Math.round(v), 1, 600)));
dispatch(eraserWidthChanged(Math.round(v)));
},
[dispatch]
);
const increment = useCallback(() => {
let newWidth = Math.round(width * 1.15);
if (newWidth === width) {
newWidth += 1;
}
onChange(newWidth);
}, [onChange, width]);
const decrement = useCallback(() => {
let newWidth = Math.round(width * 0.85);
if (newWidth === width) {
newWidth -= 1;
}
onChange(newWidth);
}, [onChange, width]);
const onChangeSlider = useCallback(
(value: number) => {
onChange(mapSliderValueToRawValue(value));
},
[onChange]
);
const onBlur = useCallback(() => {
if (isNaN(Number(localValue))) {
onChange(50);
setLocalValue(50);
} else {
onChange(localValue);
}
}, [localValue, onChange]);
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
setLocalValue(valueAsNumber);
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onBlur();
}
},
[onBlur]
);
useEffect(() => {
setLocalValue(width);
}, [width]);
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
return (
<Popover>
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<PopoverAnchor>
<NumberInput
display="flex"
alignItems="center"
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
<Popover isLazy>
<PopoverTrigger>
<CompositeNumberInput
min={1}
max={600}
value={localValue}
onChange={onChangeNumberInput}
onBlur={onBlur}
w="76px"
format={formatPx}
defaultValue={50}
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"
icon={<PiCaretDownBold />}
size="sm"
variant="link"
position="absolute"
insetInlineEnd={0}
h="full"
/>
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
value={width}
onChange={onChange}
w={24}
format={formatPx}
/>
</PopoverBody>
</PopoverContent>
</Popover>
</PopoverTrigger>
<PopoverContent w={200} py={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
</PopoverBody>
</PopoverContent>
</Popover>
</FormControl>
);
});

View File

@@ -1,4 +1,4 @@
import { Box, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } from '@invoke-ai/ui-library';
import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIColorPicker from 'common/components/IAIColorPicker';
@@ -23,13 +23,17 @@ export const ToolFillColorPicker = memo(() => {
return (
<Popover isLazy>
<PopoverTrigger>
<Flex role="button" aria-label={t('controlLayers.fill.fillColor')} tabIndex={-1} w={8} h={8}>
<Tooltip label={t('controlLayers.fill.fillColor')}>
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Box borderRadius="full" w={6} h={6} borderWidth={1} bg={rgbaColorToString(fill)} />
</Flex>
</Tooltip>
</Flex>
<Flex
as="button"
aria-label={t('controlLayers.brushColor')}
borderRadius="full"
borderWidth={1}
bg={rgbaColorToString(fill)}
w={8}
h={8}
cursor="pointer"
tabIndex={-1}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverBody minH={64}>

View File

@@ -6,7 +6,7 @@ import { memo } from 'react';
export const ToolSettings = memo(() => {
const canvasManager = useCanvasManager();
const tool = useStore(canvasManager.tool.$tool);
const tool = useStore(canvasManager.stateApi.$tool);
if (tool === 'brush') {
return <ToolBrushWidth />;
}

View File

@@ -6,14 +6,14 @@ import { useCallback } from 'react';
export const useToolIsSelected = (tool: Tool) => {
const canvasManager = useCanvasManager();
const isSelected = useStore(computed(canvasManager.tool.$tool, (t) => t === tool));
const isSelected = useStore(computed(canvasManager.stateApi.$tool, (t) => t === tool));
return isSelected;
};
export const useSelectTool = (tool: Tool) => {
const canvasManager = useCanvasManager();
const setTool = useCallback(() => {
canvasManager.tool.$tool.set(tool);
}, [canvasManager.tool.$tool, tool]);
canvasManager.stateApi.$tool.set(tool);
}, [canvasManager.stateApi.$tool, tool]);
return setTool;
};

View File

@@ -1,47 +0,0 @@
/* eslint-disable i18next/no-literal-string */
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton';
import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton';
import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey';
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys';
import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity';
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { ViewerToggle } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
import { memo } from 'react';
export const CanvasToolbar = memo(() => {
useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey();
useCanvasUndoRedoHotkeys();
useCanvasEntityQuickSwitchHotkey();
useNextPrevEntityHotkeys();
return (
<CanvasManagerProviderGate>
<Flex w="full" gap={2} alignItems="center">
<ToggleProgressButton />
<ToolChooser />
<Spacer />
<ToolSettings />
<Spacer />
<CanvasToolbarScale />
<CanvasToolbarResetViewButton />
<Spacer />
<ToolFillColorPicker />
<CanvasToolbarSaveToGalleryButton />
<CanvasSettingsPopover />
<ViewerToggle />
</Flex>
</CanvasManagerProviderGate>
);
});
CanvasToolbar.displayName = 'CanvasToolbar';

View File

@@ -1,53 +0,0 @@
import { IconButton, useShiftModifier } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { isOk, withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
const log = logger('canvas');
const [useIsSaving] = buildUseBoolean(false);
export const CanvasToolbarSaveToGalleryButton = memo(() => {
const { t } = useTranslation();
const shift = useShiftModifier();
const canvasManager = useCanvasManager();
const isSaving = useIsSaving();
const onClick = useCallback(async () => {
isSaving.setTrue();
const rect = shift ? canvasManager.stateApi.getBbox().rect : canvasManager.stage.getVisibleRect('raster_layer');
const result = await withResultAsync(() =>
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, true)
);
if (isOk(result)) {
toast({ title: t('controlLayers.savedToGalleryOk') });
} else {
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
toast({ title: t('controlLayers.savedToGalleryError'), status: 'error' });
}
isSaving.setFalse();
}, [canvasManager.compositor, canvasManager.stage, canvasManager.stateApi, isSaving, shift, t]);
return (
<IconButton
variant="ghost"
onClick={onClick}
icon={<PiFloppyDiskBold />}
isLoading={isSaving.isTrue}
aria-label={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
tooltip={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
/>
);
});
CanvasToolbarSaveToGalleryButton.displayName = 'CanvasToolbarSaveToGalleryButton';

View File

@@ -1,14 +1,19 @@
import { Button, ButtonGroup, Flex, Heading, Spacer } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import {
EntityIdentifierContext,
useEntityIdentifierContext,
} from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsCounterClockwiseBold, PiArrowsOutBold, PiCheckBold, PiXBold } from 'react-icons/pi';
import { PiArrowsCounterClockwiseBold, PiCheckBold, PiXBold } from 'react-icons/pi';
const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter }) => {
const TransformBox = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const adapter = useEntityAdapter(entityIdentifier);
const isProcessing = useStore(adapter.transformer.$isProcessing);
return (
@@ -25,19 +30,9 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
transitionDuration="normal"
>
<Heading size="md" color="base.300" userSelect="none">
{t('controlLayers.transform.transform')}
{t('controlLayers.tool.transform')}
</Heading>
<ButtonGroup isAttached={false} size="sm" w="full">
<Button
leftIcon={<PiArrowsOutBold />}
onClick={adapter.transformer.fitProxyRectToBbox}
isLoading={isProcessing}
loadingText={t('controlLayers.transform.reset')}
variant="ghost"
>
{t('controlLayers.transform.fitToBbox')}
</Button>
<Spacer />
<Button
leftIcon={<PiArrowsCounterClockwiseBold />}
onClick={adapter.transformer.resetTransform}
@@ -45,8 +40,9 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
loadingText={t('controlLayers.reset')}
variant="ghost"
>
{t('controlLayers.transform.reset')}
{t('accessibility.reset')}
</Button>
<Spacer />
<Button
leftIcon={<PiCheckBold />}
onClick={adapter.transformer.applyTransform}
@@ -54,7 +50,7 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
loadingText={t('common.apply')}
variant="ghost"
>
{t('controlLayers.transform.apply')}
{t('common.apply')}
</Button>
<Button
leftIcon={<PiXBold />}
@@ -63,7 +59,7 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
loadingText={t('common.cancel')}
variant="ghost"
>
{t('controlLayers.transform.cancel')}
{t('common.cancel')}
</Button>
</ButtonGroup>
</Flex>
@@ -74,11 +70,15 @@ TransformBox.displayName = 'Transform';
export const Transform = () => {
const canvasManager = useCanvasManager();
const adapter = useStore(canvasManager.stateApi.$transformingAdapter);
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
if (!adapter) {
if (!transformingEntity) {
return null;
}
return <TransformBox adapter={adapter} />;
return (
<EntityIdentifierContext.Provider value={transformingEntity}>
<TransformBox />
</EntityIdentifierContext.Provider>
);
};

View File

@@ -0,0 +1,53 @@
/* eslint-disable i18next/no-literal-string */
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowClockwiseBold, PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { useDispatch } from 'react-redux';
export const UndoRedoButtonGroup = memo(() => {
const { t } = useTranslation();
const dispatch = useDispatch();
const mayUndo = useAppSelector(selectCanvasMayUndo);
const handleUndo = useCallback(() => {
dispatch(canvasUndo());
}, [dispatch]);
useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, handleUndo]);
const mayRedo = useAppSelector(selectCanvasMayRedo);
const handleRedo = useCallback(() => {
dispatch(canvasRedo());
}, [dispatch]);
useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [
mayRedo,
handleRedo,
]);
return (
<ButtonGroup isAttached={false}>
<IconButton
aria-label={t('unifiedCanvas.undo')}
tooltip={t('unifiedCanvas.undo')}
onClick={handleUndo}
icon={<PiArrowCounterClockwiseBold />}
isDisabled={!mayUndo}
variant="ghost"
/>
<IconButton
aria-label={t('unifiedCanvas.redo')}
tooltip={t('unifiedCanvas.redo')}
onClick={handleRedo}
icon={<PiArrowClockwiseBold />}
isDisabled={!mayRedo}
variant="ghost"
/>
</ButtonGroup>
);
});
UndoRedoButtonGroup.displayName = 'UndoRedoButtonGroup';

View File

@@ -1,70 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import {
controlLayerAdded,
inpaintMaskAdded,
ipaAdded,
rasterLayerAdded,
rgAdded,
} from 'features/controlLayers/store/canvasSlice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
type Props = {
type: CanvasEntityIdentifier['type'];
};
export const CanvasEntityAddOfTypeButton = memo(({ type }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
switch (type) {
case 'inpaint_mask':
dispatch(inpaintMaskAdded({ isSelected: true }));
break;
case 'regional_guidance':
dispatch(rgAdded({ isSelected: true }));
break;
case 'raster_layer':
dispatch(rasterLayerAdded({ isSelected: true }));
break;
case 'control_layer':
dispatch(controlLayerAdded({ isSelected: true }));
break;
case 'ip_adapter':
dispatch(ipaAdded({ isSelected: true }));
break;
}
}, [dispatch, type]);
const label = useMemo(() => {
switch (type) {
case 'inpaint_mask':
return t('controlLayers.addInpaintMask');
case 'regional_guidance':
return t('controlLayers.addRegionalGuidance');
case 'raster_layer':
return t('controlLayers.addRasterLayer');
case 'control_layer':
return t('controlLayers.addControlLayer');
case 'ip_adapter':
return t('controlLayers.addIPAdapter');
}
}, [type, t]);
return (
<IconButton
size="sm"
aria-label={label}
tooltip={label}
variant="link"
icon={<PiPlusBold />}
onClick={onClick}
alignSelf="stretch"
/>
);
});
CanvasEntityAddOfTypeButton.displayName = 'CanvasEntityAddOfTypeButton';

View File

@@ -1,31 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
export const CanvasEntityDeleteButton = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(entityDeleted({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<IconButton
size="sm"
aria-label={t('common.delete')}
tooltip={t('common.delete')}
variant="link"
alignSelf="stretch"
icon={<PiTrashSimpleFill />}
onClick={onClick}
colorScheme="error"
/>
);
});
CanvasEntityDeleteButton.displayName = 'CanvasEntityDeleteButton';

View File

@@ -21,8 +21,7 @@ export const CanvasEntityEnabledToggle = memo(() => {
size="sm"
aria-label={t(isEnabled ? 'common.enabled' : 'common.disabled')}
tooltip={t(isEnabled ? 'common.enabled' : 'common.disabled')}
variant="link"
alignSelf="stretch"
variant="ghost"
icon={isEnabled ? <PiCircleFill /> : <PiCircleBold />}
onClick={onClick}
/>

View File

@@ -1,13 +1,11 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
import { useBoolean } from 'common/hooks/useBoolean';
import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
import { memo } from 'react';
import { PiCaretDownBold } from 'react-icons/pi';
type Props = PropsWithChildren<{
@@ -22,9 +20,6 @@ const _hover: SystemStyleObject = {
export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props) => {
const title = useEntityTypeTitle(type);
const collapse = useBoolean(true);
const canMergeVisible = useMemo(() => type === 'raster_layer' || type === 'inpaint_mask', [type]);
const canHideAll = useMemo(() => type !== 'ip_adapter', [type]);
return (
<Flex flexDir="column" w="full">
<Flex w="full">
@@ -58,9 +53,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props
</Text>
<Spacer />
</Flex>
{canMergeVisible && <CanvasEntityMergeVisibleButton type={type} />}
<CanvasEntityAddOfTypeButton type={type} />
{canHideAll && <CanvasEntityTypeIsHiddenToggle type={type} />}
{type !== 'ip_adapter' && <CanvasEntityTypeIsHiddenToggle type={type} />}
</Flex>
<Collapse in={collapse.isTrue}>
<Flex flexDir="column" gap={2} pt={2}>

View File

@@ -56,7 +56,7 @@ export const CanvasEntityHeader = memo(({ children, ...rest }: FlexProps) => {
}, [entityIdentifier]);
return (
<ContextMenu renderMenu={renderMenu}>
<ContextMenu renderMenu={renderMenu} stopImmediatePropagation>
{(ref) => (
<Flex ref={ref} gap={2} alignItems="center" p={2} {...rest}>
{children}

View File

@@ -1,22 +0,0 @@
import { Flex } from '@invoke-ai/ui-library';
import { CanvasEntityDeleteButton } from 'features/controlLayers/components/common/CanvasEntityDeleteButton';
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
import { CanvasEntityIsBookmarkedForQuickSwitchToggle } from 'features/controlLayers/components/common/CanvasEntityIsBookmarkedForQuickSwitchToggle';
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { memo } from 'react';
export const CanvasEntityHeaderCommonActions = memo(() => {
const entityIdentifier = useEntityIdentifierContext();
return (
<Flex alignSelf="stretch">
<CanvasEntityIsBookmarkedForQuickSwitchToggle />
{entityIdentifier.type !== 'ip_adapter' && <CanvasEntityIsLockedToggle />}
<CanvasEntityEnabledToggle />
<CanvasEntityDeleteButton />
</Flex>
);
});
CanvasEntityHeaderCommonActions.displayName = 'CanvasEntityHeaderCommonActions';

View File

@@ -1,36 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityIsBookmarkedForQuickSwitch } from 'features/controlLayers/hooks/useEntityIsBookmarkedForQuickSwitch';
import { bookmarkedEntityChanged } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBookmarkSimpleBold, PiBookmarkSimpleFill } from 'react-icons/pi';
export const CanvasEntityIsBookmarkedForQuickSwitchToggle = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const isBookmarked = useEntityIsBookmarkedForQuickSwitch(entityIdentifier);
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
if (isBookmarked) {
dispatch(bookmarkedEntityChanged({ entityIdentifier: null }));
} else {
dispatch(bookmarkedEntityChanged({ entityIdentifier }));
}
}, [dispatch, entityIdentifier, isBookmarked]);
return (
<IconButton
size="sm"
aria-label={t(isBookmarked ? 'controlLayers.removeBookmark' : 'controlLayers.bookmark')}
tooltip={t(isBookmarked ? 'controlLayers.removeBookmark' : 'controlLayers.bookmark')}
variant="link"
alignSelf="stretch"
icon={isBookmarked ? <PiBookmarkSimpleFill /> : <PiBookmarkSimpleBold />}
onClick={onClick}
/>
);
});
CanvasEntityIsBookmarkedForQuickSwitchToggle.displayName = 'CanvasEntityIsBookmarkedForQuickSwitchToggle';

View File

@@ -21,8 +21,7 @@ export const CanvasEntityIsLockedToggle = memo(() => {
size="sm"
aria-label={t(isLocked ? 'controlLayers.locked' : 'controlLayers.unlocked')}
tooltip={t(isLocked ? 'controlLayers.locked' : 'controlLayers.unlocked')}
variant="link"
alignSelf="stretch"
variant="ghost"
icon={isLocked ? <PiLockSimpleFill /> : <PiLockSimpleOpenBold />}
onClick={onClick}
/>

View File

@@ -12,15 +12,15 @@ export const CanvasEntityMenuItemsTransform = memo(() => {
const entityIdentifier = useEntityIdentifierContext();
const canvasManager = useCanvasManager();
const adapter = useEntityAdapter(entityIdentifier);
const isTransforming = useStore(canvasManager.stateApi.$isTranforming);
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
const onClick = useCallback(() => {
adapter.transformer.startTransform();
}, [adapter.transformer]);
return (
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={isTransforming}>
{t('controlLayers.transform.transform')}
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={Boolean(transformingEntity)}>
{t('controlLayers.tool.transform')}
</MenuItem>
);
});

View File

@@ -1,91 +0,0 @@
import { IconButton } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { useAppDispatch } from 'app/store/storeHooks';
import { isOk, withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityTypeCount } from 'features/controlLayers/hooks/useEntityTypeCount';
import { inpaintMaskAdded, rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/types';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiStackBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
const log = logger('canvas');
type Props = {
type: CanvasEntityIdentifier['type'];
};
export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const entityCount = useEntityTypeCount(type);
const onClick = useCallback(async () => {
if (type === 'raster_layer') {
const rect = canvasManager.stage.getVisibleRect('raster_layer');
const result = await withResultAsync(() =>
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, false)
);
if (isOk(result)) {
dispatch(
rasterLayerAdded({
isSelected: true,
overrides: {
objects: [imageDTOToImageObject(result.value)],
position: { x: Math.floor(rect.x), y: Math.floor(rect.y) },
},
deleteOthers: true,
})
);
toast({ title: t('controlLayers.mergeVisibleOk') });
} else {
log.error({ error: serializeError(result.error) }, 'Failed to merge visible');
toast({ title: t('controlLayers.mergeVisibleError'), status: 'error' });
}
} else if (type === 'inpaint_mask') {
const rect = canvasManager.stage.getVisibleRect('inpaint_mask');
const result = await withResultAsync(() =>
canvasManager.compositor.rasterizeAndUploadCompositeInpaintMask(rect, false)
);
if (isOk(result)) {
dispatch(
inpaintMaskAdded({
isSelected: true,
overrides: {
objects: [imageDTOToImageObject(result.value)],
position: { x: Math.floor(rect.x), y: Math.floor(rect.y) },
},
deleteOthers: true,
})
);
toast({ title: t('controlLayers.mergeVisibleOk') });
} else {
log.error({ error: serializeError(result.error) }, 'Failed to merge visible');
toast({ title: t('controlLayers.mergeVisibleError'), status: 'error' });
}
} else {
log.error({ type }, 'Unsupported type for merge visible');
}
}, [canvasManager.compositor, canvasManager.stage, dispatch, t, type]);
return (
<IconButton
size="sm"
aria-label={t('controlLayers.mergeVisible')}
tooltip={t('controlLayers.mergeVisible')}
variant="link"
icon={<PiStackBold />}
onClick={onClick}
alignSelf="stretch"
isDisabled={entityCount <= 1}
/>
);
});
CanvasEntityMergeVisibleButton.displayName = 'CanvasEntityMergeVisibleButton';

View File

@@ -4,7 +4,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import { useEntityAdapter } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useSelector } from 'react-redux';
@@ -69,7 +69,7 @@ export const CanvasEntityPreviewImage = memo(() => {
ctx.globalCompositeOperation = 'source-in';
ctx.fillRect(0, 0, rect.width, rect.height);
}
}, [cache, maskColor]);
}, [adapter.transformer, adapter.transformer.nodeRect, adapter.transformer.pixelRect, cache, maskColor]);
return (
<Flex
@@ -88,7 +88,7 @@ export const CanvasEntityPreviewImage = memo(() => {
right={0}
bottom={0}
left={0}
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
bgImage={TRANSPARENCY_CHECKER_PATTERN}
bgSize="5px"
opacity={0.1}
/>

View File

@@ -1,47 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
import {
selectBookmarkedEntityIdentifier,
selectSelectedEntityIdentifier,
} from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useCallback, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
export const useCanvasEntityQuickSwitchHotkey = () => {
const dispatch = useAppDispatch();
const [prev, setPrev] = useState<CanvasEntityIdentifier | null>(null);
const [current, setCurrent] = useState<CanvasEntityIdentifier | null>(null);
const selected = useAppSelector(selectSelectedEntityIdentifier);
const bookmarked = useAppSelector(selectBookmarkedEntityIdentifier);
// Update prev and current when selected entity changes
useEffect(() => {
if (current?.id !== selected?.id) {
setPrev(current);
setCurrent(selected);
}
}, [current, selected]);
const onQuickSwitch = useCallback(() => {
if (bookmarked) {
if (current?.id !== bookmarked.id) {
// Switch between current (non-bookmarked) and bookmarked
setPrev(current);
setCurrent(bookmarked);
dispatch(entitySelected({ entityIdentifier: bookmarked }));
} else if (prev) {
// Switch back to the last non-bookmarked entity
setCurrent(prev);
dispatch(entitySelected({ entityIdentifier: prev }));
}
} else if (prev !== null && current !== null) {
// Switch between prev and current if no bookmarked entity
setPrev(current);
setCurrent(prev);
dispatch(entitySelected({ entityIdentifier: prev }));
}
}, [bookmarked, current, dispatch, prev]);
useHotkeys('q', onQuickSwitch);
};

View File

@@ -1,27 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useDispatch } from 'react-redux';
export const useCanvasUndoRedoHotkeys = () => {
useAssertSingleton('useCanvasUndoRedo');
const dispatch = useDispatch();
const mayUndo = useAppSelector(selectCanvasMayUndo);
const handleUndo = useCallback(() => {
dispatch(canvasUndo());
}, [dispatch]);
useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, handleUndo]);
const mayRedo = useAppSelector(selectCanvasMayRedo);
const handleRedo = useCallback(() => {
dispatch(canvasRedo());
}, [dispatch]);
useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [
mayRedo,
handleRedo,
]);
};

View File

@@ -1,18 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
export const useEntityIsBookmarkedForQuickSwitch = (entityIdentifier: CanvasEntityIdentifier) => {
const selectIsBookmarkedForQuickSwitch = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
return canvas.bookmarkedEntityIdentifier?.id === entityIdentifier.id;
}),
[entityIdentifier]
);
const isBookmarkedForQuickSwitch = useAppSelector(selectIsBookmarkedForQuickSwitch);
return isBookmarkedForQuickSwitch;
};

View File

@@ -0,0 +1,24 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import { type CanvasEntityIdentifier, isDrawableEntity } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
export const useEntityObjectCount = (entityIdentifier: CanvasEntityIdentifier) => {
const selectObjectCount = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
const entity = selectEntity(canvas, entityIdentifier);
if (!entity) {
return 0;
} else if (isDrawableEntity(entity)) {
return entity.objects.length;
} else {
return 0;
}
}),
[entityIdentifier]
);
const objectCount = useAppSelector(selectObjectCount);
return objectCount;
};

View File

@@ -1,5 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useEntityObjectCount } from 'features/controlLayers/hooks/useEntityObjectCount';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
@@ -19,27 +20,34 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
const { t } = useTranslation();
const selectName = useMemo(() => createSelectName(entityIdentifier), [entityIdentifier]);
const name = useAppSelector(selectName);
const objectCount = useEntityObjectCount(entityIdentifier);
const title = useMemo(() => {
if (name) {
return name;
}
switch (entityIdentifier.type) {
case 'inpaint_mask':
return t('controlLayers.inpaintMask');
case 'control_layer':
return t('controlLayers.controlLayer');
case 'raster_layer':
return t('controlLayers.rasterLayer');
case 'ip_adapter':
return t('controlLayers.globalIPAdapter');
case 'regional_guidance':
return t('controlLayers.regionalGuidance');
default:
assert(false, 'Unexpected entity type');
const parts: string[] = [];
if (entityIdentifier.type === 'inpaint_mask') {
parts.push(t('controlLayers.inpaintMask', { count: 1 }));
} else if (entityIdentifier.type === 'control_layer') {
parts.push(t('controlLayers.controlLayer', { count: 1 }));
} else if (entityIdentifier.type === 'raster_layer') {
parts.push(t('controlLayers.rasterLayer', { count: 1 }));
} else if (entityIdentifier.type === 'ip_adapter') {
parts.push(t('common.ipAdapter', { count: 1 }));
} else if (entityIdentifier.type === 'regional_guidance') {
parts.push(t('controlLayers.regionalGuidance', { count: 1 }));
} else {
assert(false, 'Unexpected entity type');
}
}, [entityIdentifier.type, name, t]);
if (objectCount > 0) {
parts.push(`(${objectCount})`);
}
return parts.join(' ');
}, [entityIdentifier.type, name, objectCount, t]);
return title;
};

View File

@@ -8,15 +8,15 @@ export const useEntityTypeString = (type: CanvasEntityIdentifier['type']): strin
const typeString = useMemo(() => {
switch (type) {
case 'control_layer':
return t('controlLayers.controlLayer');
return t('controlLayers.controlLayer', { count: 0 });
case 'raster_layer':
return t('controlLayers.rasterLayer');
return t('controlLayers.rasterLayer', { count: 0 });
case 'inpaint_mask':
return t('controlLayers.inpaintMask');
return t('controlLayers.inpaintMask', { count: 0 });
case 'regional_guidance':
return t('controlLayers.regionalGuidance');
return t('controlLayers.regionalGuidance', { count: 0 });
case 'ip_adapter':
return t('controlLayers.globalIPAdapter');
return t('controlLayers.ipAdapter', { count: 0 });
default:
return '';
}

View File

@@ -22,7 +22,7 @@ export const useEntityTypeTitle = (type: CanvasEntityIdentifier['type']): string
case 'regional_guidance':
return t('controlLayers.regionalGuidance_withCount', { count, context });
case 'ip_adapter':
return t('controlLayers.globalIPAdapters_withCount', { count, context });
return t('controlLayers.ipAdapters_withCount', { count, context });
default:
return '';
}

View File

@@ -4,7 +4,7 @@ import { useMemo } from 'react';
export const useIsTransforming = () => {
const canvasManager = useCanvasManager();
const transformingEntity = useStore(canvasManager.stateApi.$transformingAdapter);
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
const isTransforming = useMemo(() => {
return Boolean(transformingEntity);
}, [transformingEntity]);

View File

@@ -48,6 +48,7 @@ export const useDefaultControlAdapter = (): ControlNetConfig | T2IAdapterConfig
return defaultControlAdapter;
};
/** @knipignore */
export const useDefaultIPAdapter = (): IPAdapterConfig => {
const [modelConfigs] = useIPAdapterModels();

View File

@@ -1,76 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
import { selectAllEntities, selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasEntityState } from 'features/controlLayers/store/types';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
const selectNextEntityIdentifier = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const selectedEntityIdentifier = canvas.selectedEntityIdentifier;
const allEntities = selectAllEntities(canvas);
let nextEntity: CanvasEntityState | null = null;
if (!selectedEntityIdentifier) {
nextEntity = allEntities[0] ?? null;
} else {
const selectedEntityIndex = allEntities.findIndex((entity) => entity.id === selectedEntityIdentifier.id);
nextEntity = allEntities[(selectedEntityIndex + 1) % allEntities.length] ?? null;
}
if (!nextEntity) {
return null;
}
return getEntityIdentifier(nextEntity);
});
const selectPrevEntityIdentifier = createMemoizedSelector(selectCanvasSlice, (canvas) => {
const selectedEntityIdentifier = canvas.selectedEntityIdentifier;
const allEntities = selectAllEntities(canvas);
let prevEntity: CanvasEntityState | null = null;
if (!selectedEntityIdentifier) {
prevEntity = allEntities[0] ?? null;
} else {
const selectedEntityIndex = allEntities.findIndex((entity) => entity.id === selectedEntityIdentifier.id);
prevEntity = allEntities[(selectedEntityIndex - 1 + allEntities.length) % allEntities.length] ?? null;
}
if (!prevEntity) {
return null;
}
return getEntityIdentifier(prevEntity);
});
export const useNextPrevEntityHotkeys = () => {
useAssertSingleton('useNextPrevEntityHotkeys');
const dispatch = useAppDispatch();
const nextEntityIdentifier = useAppSelector(selectNextEntityIdentifier);
const prevEntityIdentifier = useAppSelector(selectPrevEntityIdentifier);
const selectNextEntity = useCallback(() => {
if (nextEntityIdentifier) {
dispatch(entitySelected({ entityIdentifier: nextEntityIdentifier }));
}
}, [dispatch, nextEntityIdentifier]);
const selectPrevEntity = useCallback(() => {
if (prevEntityIdentifier) {
dispatch(entitySelected({ entityIdentifier: prevEntityIdentifier }));
}
}, [dispatch, prevEntityIdentifier]);
useHotkeys(
// “ === alt+[
['alt+[', '“'],
selectPrevEntity,
{ preventDefault: true, ignoreModifiers: true },
[selectPrevEntity]
);
useHotkeys(
// === alt+]
['alt+]', ''],
selectNextEntity,
{ preventDefault: true, ignoreModifiers: true },
[selectNextEntity]
);
};

View File

@@ -1,72 +1,45 @@
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
type CanvasBackgroundModuleConfig = {
GRID_LINE_COLOR_COARSE: string;
GRID_LINE_COLOR_FINE: string;
};
const DEFAULT_CONFIG: CanvasBackgroundModuleConfig = {
GRID_LINE_COLOR_COARSE: getArbitraryBaseColor(27),
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 {
export class CanvasBackgroundModule extends CanvasModuleABC {
readonly type = 'background';
static GRID_LINE_COLOR_COARSE = getArbitraryBaseColor(27);
static GRID_LINE_COLOR_FINE = getArbitraryBaseColor(18);
id: string;
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) {
super();
this.id = getPrefixedId(this.type);
this.manager = manager;
this.parent = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating module');
this.log.debug('Creating background module');
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }), lines: [] };
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }) };
/**
* The background grid should be rendered when the stage attributes change:
* - scale
* - position
* - size
*/
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
this.subscriptions.add(
this.manager.stateApi.$stageAttrs.listen(() => {
this.render();
})
);
}
/**
* Renders the background grid.
*/
render = () => {
render() {
const settings = this.manager.stateApi.getSettings();
if (!settings.dynamicGrid) {
@@ -76,10 +49,11 @@ 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 = CanvasBackgroundModule.getGridSpacing(scale);
const gridSpacing = this.getGridSpacing(scale);
const stageRect = {
x1: 0,
y1: 0,
@@ -118,44 +92,47 @@ 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;
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);
this.konva.layer.add(
new Konva.Line({
x: _x,
y: gridFullRect.y1,
points: [0, 0, 0, ySize],
stroke: _x % 64 ? CanvasBackgroundModule.GRID_LINE_COLOR_FINE : CanvasBackgroundModule.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
})
);
}
for (let i = 0; i < ySteps; i++) {
_y = gridFullRect.y1 + i * gridSpacing;
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);
this.konva.layer.add(
new Konva.Line({
x: gridFullRect.x1,
y: _y,
points: [0, 0, xSize, 0],
stroke: _y % 64 ? CanvasBackgroundModule.GRID_LINE_COLOR_FINE : CanvasBackgroundModule.GRID_LINE_COLOR_COARSE,
strokeWidth,
listening: false,
})
);
}
}
destroy = () => {
this.log.trace('Destroying background module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.layer.destroy();
};
/**
* Gets the grid line spacing for the dynamic grid.
*
* The value depends on the stage scale - at higher scales, the grid spacing is smaller.
*
* Gets the grid spacing. 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
*/
static getGridSpacing = (scale: number): number => {
getGridSpacing = (scale: number): number => {
if (scale >= 2) {
return 8;
}
@@ -174,9 +151,15 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
return 256;
};
destroy = () => {
this.log.trace('Destroying module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.layer.destroy();
repr = () => {
return {
id: this.id,
path: this.path,
type: this.type,
};
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,8 +1,10 @@
import type { SerializableObject } from 'common/types';
import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMultiple';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import type { Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
@@ -21,57 +23,43 @@ 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 {
export class CanvasBboxModule extends CanvasModuleABC {
readonly type = 'bbox';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
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
*/
parent: CanvasPreviewModule;
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) {
constructor(parent: CanvasPreviewModule) {
super();
this.id = getPrefixedId(this.type);
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating bbox module');
// Set the initial aspect ratio buffer per app state.
// Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when
// transforming the bbox.
const bbox = this.manager.stateApi.getBbox();
this.$aspectRatioBuffer.set(bbox.rect.width / bbox.rect.height);
const $aspectRatioBuffer = atom(bbox.rect.width / bbox.rect.height);
this.konva = {
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({
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({
name: `${this.type}:rect`,
listening: false,
strokeEnabled: false,
@@ -97,43 +85,171 @@ export class CanvasBboxModule extends CanvasModuleBase {
anchorCornerRadius: 3,
shiftBehavior: 'none', // we will implement our own shift behavior
centeredScaling: false,
anchorStyleFunc: this.anchorStyleFunc,
anchorDragBoundFunc: this.anchorDragBoundFunc,
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,
};
},
}),
};
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.proxyRect.on('dragmove', this.onDragMove);
this.konva.transformer.on('transform', this.onTransform);
this.konva.transformer.on('transformend', this.onTransformEnd);
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;
}
// The transformer will always be transforming the proxy rect
this.konva.transformer.nodes([this.konva.proxyRect]);
this.konva.group.add(this.konva.proxyRect);
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);
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.tool.$tool.listen(this.render));
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');
this.log.trace('Rendering bbox module');
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
const tool = this.manager.tool.$tool.get();
const bbox = this.manager.stateApi.getBbox();
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.proxyRect.setAttrs({
x,
y,
width,
height,
this.parent.getLayer().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,
scaleX: 1,
scaleY: 1,
listening: tool === 'bbox',
@@ -144,174 +260,21 @@ export class CanvasBboxModule extends CanvasModuleBase {
});
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.trace('Destroying module');
this.log.trace('Destroying bbox 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,
};
getLoggingContext = (): SerializableObject => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,146 +0,0 @@
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
type BrushToolPreviewConfig = {
/**
* The inner border color for the brush tool preview.
*/
BORDER_INNER_COLOR: string;
/**
* The outer border color for the brush tool preview.
*/
BORDER_OUTER_COLOR: string;
};
const DEFAULT_CONFIG: BrushToolPreviewConfig = {
BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
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';
id: string;
path: string[];
parent: CanvasToolModule;
manager: CanvasManager;
log: Logger;
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;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
constructor(parent: CanvasToolModule) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:brush_group`, listening: false }),
fillCircle: new Konva.Circle({
name: `${this.type}:brush_fill_circle`,
listening: false,
strokeEnabled: false,
}),
innerBorder: new Konva.Ring({
name: `${this.type}:brush_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:brush_outer_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
};
this.konva.group.add(this.konva.fillCircle, this.konva.innerBorder, this.konva.outerBorder);
}
render = () => {
const cursorPos = this.manager.tool.$lastCursorPos.get();
// If the cursor position is not available, do not update the brush preview. The tool module will handle visiblity.
if (!cursorPos) {
return;
}
const toolState = this.manager.stateApi.getToolState();
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const radius = toolState.brush.width / 2;
// The circle is scaled
this.konva.fillCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: rgbaColorToString(brushPreviewFill),
});
// But the borders are in screen-pixels
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
setVisibility = (visible: boolean) => {
this.konva.group.visible(visible);
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
config: this.config,
};
};
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
}

View File

@@ -1,92 +1,54 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { GenerationMode } from 'features/controlLayers/store/types';
import { LRUCache } from 'lru-cache';
import type { Logger } from 'roarr';
type CanvasCacheModuleConfig = {
/**
* The maximum size of the image name cache.
*/
imageNameCacheSize: number;
/**
* The maximum size of the canvas element cache.
*/
canvasElementCacheSize: number;
/**
* The maximum size of the generation mode cache.
*/
generationModeCacheSize: number;
};
const DEFAULT_CONFIG: CanvasCacheModuleConfig = {
imageNameCacheSize: 100,
canvasElementCacheSize: 32,
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 {
export class CanvasCacheModule extends CanvasModuleABC {
readonly type = 'cache';
id: string;
path: string[];
log: Logger;
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
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<string, string>({ 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<string, HTMLCanvasElement>({ 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<string, GenerationMode>({ max: this.config.generationModeCacheSize });
imageNameCache = new LRUCache<string, string>({ max: 100 });
canvasElementCache = new LRUCache<string, HTMLCanvasElement>({ max: 32 });
generationModeCache = new LRUCache<string, GenerationMode>({ max: 100 });
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId('cache');
this.manager = manager;
this.parent = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating cache module');
}
/**
* Clears all caches.
*/
clearAll = () => {
this.canvasElementCache.clear();
this.imageNameCache.clear();
this.generationModeCache.clear();
};
repr = () => {
return {
id: this.id,
path: this.path,
type: this.type,
};
};
destroy = () => {
this.log.debug('Destroying cache module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.clearAll();
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,293 +0,0 @@
import { rgbColorToString } from 'common/util/colorCodeTransformers';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
type ColorPickerToolConfig = {
/**
* The inner radius of the ring.
*/
RING_INNER_RADIUS: number;
/**
* The outer radius of the ring.
*/
RING_OUTER_RADIUS: number;
/**
* The inner border color of the outside edge of ring.
*/
RING_BORDER_INNER_COLOR: string;
/**
* The outer border color of the outside edge of ring.
*/
RING_BORDER_OUTER_COLOR: string;
/**
* The radius of the space between the center of the ring and start of the crosshair lines.
*/
CROSSHAIR_INNER_RADIUS: number;
/**
* The length of the crosshair lines.
*/
CROSSHAIR_LINE_LENGTH: number;
/**
* The thickness of the crosshair lines.
*/
CROSSHAIR_LINE_THICKNESS: number;
/**
* The color of the crosshair lines.
*/
CROSSHAIR_LINE_COLOR: string;
/**
* The thickness of the crosshair lines borders
*/
CROSSHAIR_LINE_BORDER_THICKNESS: number;
/**
* The color of the crosshair line borders.
*/
CROSSHAIR_BORDER_COLOR: string;
};
const DEFAULT_CONFIG: ColorPickerToolConfig = {
RING_INNER_RADIUS: 25,
RING_OUTER_RADIUS: 35,
RING_BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
RING_BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
CROSSHAIR_INNER_RADIUS: 5,
CROSSHAIR_LINE_THICKNESS: 1.5,
CROSSHAIR_LINE_BORDER_THICKNESS: 0.75,
CROSSHAIR_LINE_LENGTH: 10,
CROSSHAIR_LINE_COLOR: 'rgba(0,0,0,1)',
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';
id: string;
path: string[];
parent: CanvasToolModule;
manager: CanvasManager;
log: Logger;
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;
ringCandidateColor: Konva.Ring;
ringCurrentColor: Konva.Arc;
ringInnerBorder: Konva.Ring;
ringOuterBorder: Konva.Ring;
crosshairNorthInner: Konva.Line;
crosshairNorthOuter: Konva.Line;
crosshairEastInner: Konva.Line;
crosshairEastOuter: Konva.Line;
crosshairSouthInner: Konva.Line;
crosshairSouthOuter: Konva.Line;
crosshairWestInner: Konva.Line;
crosshairWestOuter: Konva.Line;
};
constructor(parent: CanvasToolModule) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:color_picker_group`, listening: false }),
ringCandidateColor: new Konva.Ring({
name: `${this.type}:color_picker_candidate_color_ring`,
innerRadius: 0,
outerRadius: 0,
strokeEnabled: false,
}),
ringCurrentColor: new Konva.Arc({
name: `${this.type}:color_picker_current_color_arc`,
innerRadius: 0,
outerRadius: 0,
angle: 180,
strokeEnabled: false,
}),
ringInnerBorder: new Konva.Ring({
name: `${this.type}:color_picker_inner_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: this.config.RING_BORDER_INNER_COLOR,
strokeEnabled: false,
}),
ringOuterBorder: new Konva.Ring({
name: `${this.type}:color_picker_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: this.config.RING_BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
crosshairNorthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairNorthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_north2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
crosshairEastInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairEastOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_east2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
crosshairSouthInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairSouthOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_south2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
crosshairWestInner: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west1_line`,
stroke: this.config.CROSSHAIR_LINE_COLOR,
}),
crosshairWestOuter: new Konva.Line({
name: `${this.type}:color_picker_crosshair_west2_line`,
stroke: this.config.CROSSHAIR_BORDER_COLOR,
}),
};
this.konva.group.add(
this.konva.ringCandidateColor,
this.konva.ringCurrentColor,
this.konva.ringInnerBorder,
this.konva.ringOuterBorder,
this.konva.crosshairNorthOuter,
this.konva.crosshairNorthInner,
this.konva.crosshairEastOuter,
this.konva.crosshairEastInner,
this.konva.crosshairSouthOuter,
this.konva.crosshairSouthInner,
this.konva.crosshairWestOuter,
this.konva.crosshairWestInner
);
}
/**
* Renders the color picker tool preview on the canvas.
*/
render = () => {
const cursorPos = this.manager.tool.$lastCursorPos.get();
// If the cursor position is not available, do not render the preview. The tool module will handle visibility.
if (!cursorPos) {
return;
}
const toolState = this.manager.stateApi.getToolState();
const colorUnderCursor = this.parent.$colorUnderCursor.get();
const colorPickerInnerRadius = this.manager.stage.getScaledPixels(this.config.RING_INNER_RADIUS);
const colorPickerOuterRadius = this.manager.stage.getScaledPixels(this.config.RING_OUTER_RADIUS);
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.ringCandidateColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(colorUnderCursor),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.ringCurrentColor.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
fill: rgbColorToString(toolState.fill),
innerRadius: colorPickerInnerRadius,
outerRadius: colorPickerOuterRadius,
});
this.konva.ringInnerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius,
outerRadius: colorPickerOuterRadius + onePixel,
});
this.konva.ringOuterBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: colorPickerOuterRadius + onePixel,
outerRadius: colorPickerOuterRadius + twoPixels,
});
const size = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_LINE_LENGTH);
const space = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_INNER_RADIUS);
const innerThickness = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_LINE_THICKNESS);
const outerThickness = this.manager.stage.getScaledPixels(
this.config.CROSSHAIR_LINE_THICKNESS + this.config.CROSSHAIR_LINE_BORDER_THICKNESS * 2
);
this.konva.crosshairNorthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.crosshairNorthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
});
this.konva.crosshairEastOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.crosshairEastInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
});
this.konva.crosshairSouthOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.crosshairSouthInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
});
this.konva.crosshairWestOuter.setAttrs({
strokeWidth: outerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
this.konva.crosshairWestInner.setAttrs({
strokeWidth: innerThickness,
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
});
};
setVisibility = (visible: boolean) => {
this.konva.group.visible(visible);
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
config: this.config,
};
};
destroy = () => {
this.log.debug('Destroying color picker tool preview module');
this.konva.group.destroy();
};
}

View File

@@ -1,6 +1,6 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import {
canvasToBlob,
canvasToImageData,
@@ -15,36 +15,24 @@ 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 {
export class CanvasCompositorModule extends CanvasModuleABC {
readonly type = 'compositor';
id: string;
path: string[];
log: Logger;
parent: CanvasManager;
manager: CanvasManager;
subscriptions = new Set<() => void>();
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId('canvas_compositor');
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
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()) {
@@ -55,35 +43,16 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return ids;
};
/**
* 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<string, SerializableObject> = {
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;
getCompositeInpaintMaskEntityIds = (): string[] => {
const ids = [];
for (const adapter of this.manager.adapters.inpaintMasks.values()) {
if (adapter.state.isEnabled && adapter.renderer.hasObjects()) {
ids.push(adapter.id);
}
data[id] = adapter.getHashableState();
}
return stableHash(data);
return ids;
};
/**
* 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);
@@ -116,99 +85,6 @@ 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<ImageDTO> => {
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<ImageDTO> => {
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<string, SerializableObject> = {
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);
@@ -241,35 +117,63 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return canvas;
};
/**
* 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');
const canvas = this.getCompositeInpaintMaskCanvas(rect);
const blob = await canvasToBlob(canvas);
if (this.manager._isDebugging) {
previewBlob(blob, 'Composite inpaint mask canvas');
getCompositeRasterLayerHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
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 uploadImage(blob, 'composite-inpaint-mask.png', 'general', !saveToGallery);
return stableHash(data);
};
getCompositeInpaintMaskHash = (extra: SerializableObject): string => {
const data: Record<string, SerializableObject> = {
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);
};
getCompositeRasterLayerImageDTO = async (rect: Rect): Promise<ImageDTO> => {
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;
}
}
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');
}
imageDTO = await uploadImage(blob, 'composite-raster-layer.png', 'general', true);
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
return imageDTO;
};
/**
* 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<ImageDTO> => {
let imageDTO: ImageDTO | null = null;
@@ -284,29 +188,19 @@ export class CanvasCompositorModule extends CanvasModuleBase {
}
}
imageDTO = await this.rasterizeAndUploadCompositeInpaintMask(rect, false);
this.log.trace({ rect }, 'Rasterizing composite inpaint mask');
const canvas = this.getCompositeInpaintMaskCanvas(rect);
const blob = await canvasToBlob(canvas);
if (this.manager._isDebugging) {
previewBlob(blob, 'Composite inpaint mask canvas');
}
imageDTO = await uploadImage(blob, 'composite-inpaint-mask.png', 'general', true);
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
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();
@@ -347,4 +241,21 @@ export class CanvasCompositorModule extends CanvasModuleBase {
this.manager.cache.generationModeCache.set(hash, generationMode);
return generationMode;
}
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
destroy = () => {
this.log.trace('Destroying compositor module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
import { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { getLastPointOfLine } from 'features/controlLayers/konva/util';
import type {
CanvasBrushLineState,
@@ -22,63 +22,33 @@ 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 {
export class CanvasEntityLayerAdapter extends CanvasModuleABC {
readonly type = 'entity_layer_adapter';
id: string;
path: string[];
manager: CanvasManager;
parent: CanvasManager;
subscriptions = new Set<() => void>();
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;
/**
* Whether this is the first render of the entity layer.
*/
isFirstRender: boolean = true;
constructor(state: CanvasEntityLayerAdapter['state'], manager: CanvasEntityLayerAdapter['manager']) {
super();
this.id = state.id;
this.manager = manager;
this.parent = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating module');
this.log.debug({ state }, 'Creating layer adapter module');
this.state = state;
@@ -104,7 +74,15 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
return getEntityIdentifier(this.state);
};
update = async (arg?: { state: CanvasEntityLayerAdapter['state'] }): Promise<void> => {
destroy = (): void => {
this.log.debug('Destroying layer adapter module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.renderer.destroy();
this.transformer.destroy();
this.konva.layer.destroy();
};
update = async (arg?: { state: CanvasEntityLayerAdapter['state'] }) => {
const state = get(arg, 'state', this.state);
const prevState = this.state;
@@ -166,7 +144,23 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
}
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
transformer: this.transformer.repr(),
renderer: this.renderer.repr(),
};
};
getLoggingContext = (): SerializableObject => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
getCanvas = (rect?: Rect): HTMLCanvasElement => {
// TODO(psyche) - cache this - maybe with package `memoizee`? Would require careful review of cache invalidation
this.log.trace({ rect }, 'Getting canvas');
// The opacity may have been changed in response to user selecting a different entity category, so we must restore
// the original opacity before rendering the canvas
@@ -208,21 +202,30 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
return null;
};
destroy = (): void => {
this.log.debug('Destroying module');
this.renderer.destroy();
this.transformer.destroy();
this.konva.layer.destroy();
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
transformer: this.transformer.repr(),
renderer: this.renderer.repr(),
logDebugInfo(msg = 'Debug info') {
const info = {
repr: this.repr(),
interactionRectAttrs: {
x: this.transformer.konva.proxyRect.x(),
y: this.transformer.konva.proxyRect.y(),
scaleX: this.transformer.konva.proxyRect.scaleX(),
scaleY: this.transformer.konva.proxyRect.scaleY(),
width: this.transformer.konva.proxyRect.width(),
height: this.transformer.konva.proxyRect.height(),
rotation: this.transformer.konva.proxyRect.rotation(),
},
objectGroupAttrs: {
x: this.renderer.konva.objectGroup.x(),
y: this.renderer.konva.objectGroup.y(),
scaleX: this.renderer.konva.objectGroup.scaleX(),
scaleY: this.renderer.konva.objectGroup.scaleY(),
width: this.renderer.konva.objectGroup.width(),
height: this.renderer.konva.objectGroup.height(),
rotation: this.renderer.konva.objectGroup.rotation(),
offsetX: this.renderer.konva.objectGroup.offsetX(),
offsetY: this.renderer.konva.objectGroup.offsetY(),
},
};
};
this.log.trace(info, msg);
}
}

View File

@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
import { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { getLastPointOfLine } from 'features/controlLayers/konva/util';
import type {
CanvasBrushLineState,
@@ -21,14 +21,14 @@ import { get, omit } from 'lodash-es';
import type { Logger } from 'roarr';
import stableHash from 'stable-hash';
export class CanvasEntityMaskAdapter extends CanvasModuleBase {
export class CanvasEntityMaskAdapter extends CanvasModuleABC {
readonly type = 'entity_mask_adapter';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasInpaintMaskState | CanvasRegionalGuidanceState;
@@ -44,12 +44,11 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
constructor(state: CanvasEntityMaskAdapter['state'], manager: CanvasEntityMaskAdapter['manager']) {
super();
this.id = state.id;
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating module');
this.log.debug({ state }, 'Creating mask adapter module');
this.state = state;
@@ -75,6 +74,14 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
return getEntityIdentifier(this.state);
};
destroy = (): void => {
this.log.debug('Destroying mask adapter module');
this.transformer.destroy();
this.renderer.destroy();
this.konva.layer.destroy();
};
update = async (arg?: { state: CanvasEntityMaskAdapter['state'] }) => {
const state = get(arg, 'state', this.state);
@@ -150,6 +157,15 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
return null;
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
};
};
getHashableState = (): SerializableObject => {
const keysToOmit: (keyof CanvasEntityMaskAdapter['state'])[] = ['fill', 'name', 'opacity'];
return omit(this.state, keysToOmit);
@@ -164,6 +180,7 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
};
getCanvas = (rect?: Rect): HTMLCanvasElement => {
// TODO(psyche): Cache this?
// The opacity may have been changed in response to user selecting a different entity category, and the mask regions
// should be fully opaque - set opacity to 1 before rendering the canvas
const attrs: GroupConfig = { opacity: 1 };
@@ -171,19 +188,7 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
return canvas;
};
destroy = () => {
this.log.debug('Destroying module');
this.transformer.destroy();
this.renderer.destroy();
this.konva.layer.destroy();
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
state: deepClone(this.state),
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -2,7 +2,7 @@ import { rgbColorToString } from 'common/util/colorCodeTransformers';
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasObjectBrushLineRenderer } from 'features/controlLayers/konva/CanvasObjectBrushLineRenderer';
import { CanvasObjectEraserLineRenderer } from 'features/controlLayers/konva/CanvasObjectEraserLineRenderer';
import { CanvasObjectImageRenderer } from 'features/controlLayers/konva/CanvasObjectImageRenderer';
@@ -60,7 +60,7 @@ type AnyObjectState = CanvasBrushLineState | CanvasEraserLineState | CanvasImage
/**
* Handles rendering of objects for a canvas entity.
*/
export class CanvasEntityRenderer extends CanvasModuleBase {
export class CanvasEntityRenderer extends CanvasModuleABC {
readonly type = 'entity_renderer';
id: string;
@@ -125,25 +125,16 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
} | null;
};
/**
* The entity's object group as a canvas element along with the pixel rect of the entity at the time the canvas was
* drawn.
*
* Technically, this is an internal Konva object, created when a Konva node's `.cache()` method is called. We cache
* the object group after every update, so we get this as a "free" side effect.
*
* This is used to render the entity's preview in the control layer.
*/
$canvasCache = atom<{ canvas: HTMLCanvasElement; rect: Rect } | null>(null);
constructor(parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.path = this.parent.path.concat(this.id);
this.manager = parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating entity object renderer module');
this.konva = {
objectGroup: new Konva.Group({ name: `${this.type}:object_group`, listening: false }),
@@ -175,7 +166,7 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
// user switches tool mid-drawing, for example by pressing space to pan the stage. It's easy to press space
// to pan _before_ releasing the mouse button, which would cause the buffer to be lost if we didn't commit it.
this.subscriptions.add(
this.manager.tool.$tool.listen(() => {
this.manager.stateApi.$tool.listen(() => {
this.commitBuffer();
})
);
@@ -183,7 +174,7 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
// The compositing rect must cover the whole stage at all times. When the stage is scaled, moved or resized, we
// need to update the compositing rect to match the stage.
this.subscriptions.add(
this.manager.stage.$stageAttrs.listen(() => {
this.manager.stateApi.$stageAttrs.listen(() => {
if (this.konva.compositing && this.parent.type === 'entity_mask_adapter') {
this.updateCompositingRectSize();
}
@@ -256,7 +247,7 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
this.log.trace('Updating compositing rect size');
assert(this.konva.compositing, 'Missing compositing rect');
const { x, y, width, height, scale } = this.manager.stage.$stageAttrs.get();
const { x, y, width, height, scale } = this.manager.stateApi.$stageAttrs.get();
this.konva.compositing.rect.setAttrs({
x: -x / scale,
@@ -558,18 +549,17 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
};
updatePreviewCanvas = debounce(() => {
if (this.parent.transformer.$isPendingRectCalculation.get()) {
if (this.parent.transformer.isPendingRectCalculation) {
return;
}
const pixelRect = this.parent.transformer.$pixelRect.get();
if (pixelRect.width === 0 || pixelRect.height === 0) {
if (this.parent.transformer.pixelRect.width === 0 || this.parent.transformer.pixelRect.height === 0) {
return;
}
try {
// TODO(psyche): This is an internal Konva method, so it may break in the future. Can we make this API public?
const canvas = this.konva.objectGroup._getCachedSceneCanvas()._canvas as HTMLCanvasElement | undefined | null;
if (canvas) {
const nodeRect = this.parent.transformer.$nodeRect.get();
const nodeRect = this.parent.transformer.nodeRect;
const pixelRect = this.parent.transformer.pixelRect;
const rect = {
x: pixelRect.x - nodeRect.x,
y: pixelRect.y - nodeRect.y,
@@ -614,8 +604,11 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
return imageData;
};
/**
* Destroys this renderer and all of its object renderers.
*/
destroy = () => {
this.log.debug('Destroying module');
this.log.debug('Destroying entity object renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
for (const renderer of this.renderers.values()) {
renderer.destroy();
@@ -623,6 +616,10 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
this.renderers.clear();
};
/**
* Gets a serializable representation of the renderer.
* @returns A serializable representation of the renderer.
*/
repr = () => {
return {
id: this.id,
@@ -633,4 +630,8 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
buffer: this.bufferRenderer?.repr(),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,116 +1,66 @@
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { canvasToImageData, getEmptyRect, getPrefixedId } from 'features/controlLayers/konva/util';
import type { Coordinate, Rect, RectWithRotation } from 'features/controlLayers/store/types';
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group';
import { debounce, get } from 'lodash-es';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
type CanvasEntityTransformerConfig = {
/**
* The debounce time in milliseconds for calculating the rect of the parent entity
*/
RECT_CALC_DEBOUNCE_MS: number;
/**
* The padding around the scaling transform anchors for hit detection
*/
ANCHOR_HIT_PADDING: number;
/**
* The padding around the parent entity when drawing the rect outline
*/
OUTLINE_PADDING: number;
/**
* The color of the rect outline
*/
OUTLINE_COLOR: string;
/**
* The fill color of the scaling transform anchors
*/
SCALE_ANCHOR_FILL_COLOR: string;
/**
* The stroke color of the scaling transform anchors
*/
SCALE_ANCHOR_STROKE_COLOR: string;
/**
* The corner radius ratio of the scaling transform anchors
*/
SCALE_ANCHOR_CORNER_RADIUS_RATIO: number;
/**
* The stroke width of the scaling transform anchors
*/
SCALE_ANCHOR_STROKE_WIDTH: number;
/**
* The size of the scaling transform anchors
*/
SCALE_ANCHOR_SIZE: number;
/**
* The fill color of the rotation transform anchor
*/
ROTATE_ANCHOR_FILL_COLOR: string;
/**
* The stroke color of the rotation transform anchor
*/
ROTATE_ANCHOR_STROKE_COLOR: string;
/**
* The size (height/width) of the rotation transform anchor
*/
ROTATE_ANCHOR_SIZE: number;
};
const DEFAULT_CONFIG: CanvasEntityTransformerConfig = {
RECT_CALC_DEBOUNCE_MS: 300,
ANCHOR_HIT_PADDING: 10,
OUTLINE_PADDING: 0,
OUTLINE_COLOR: 'hsl(200 76% 50% / 1)', // invokeBlue.500
SCALE_ANCHOR_FILL_COLOR: 'hsl(200 76% 50% / 1)', // invokeBlue.500
SCALE_ANCHOR_STROKE_COLOR: 'hsl(200 76% 77% / 1)', // invokeBlue.200
SCALE_ANCHOR_CORNER_RADIUS_RATIO: 0.5,
SCALE_ANCHOR_STROKE_WIDTH: 2,
SCALE_ANCHOR_SIZE: 8,
ROTATE_ANCHOR_FILL_COLOR: 'hsl(200 76% 95% / 1)', // invokeBlue.50
ROTATE_ANCHOR_STROKE_COLOR: 'hsl(200 76% 40% / 1)', // invokeBlue.700
ROTATE_ANCHOR_SIZE: 12,
};
export class CanvasEntityTransformer extends CanvasModuleBase {
/**
* The CanvasTransformer class is responsible for managing the transformation of a canvas entity:
* - Moving
* - Resizing
* - Rotating
*
* It renders an outline when dragging and resizing the entity, with transform anchors for resizing and rotation.
*/
export class CanvasEntityTransformer extends CanvasModuleABC {
readonly type = 'entity_transformer';
static RECT_CALC_DEBOUNCE_MS = 300;
static OUTLINE_PADDING = 0;
static OUTLINE_COLOR = 'hsl(200 76% 50% / 1)'; // invokeBlue.500
static ANCHOR_FILL_COLOR = CanvasEntityTransformer.OUTLINE_COLOR;
static ANCHOR_STROKE_COLOR = 'hsl(200 76% 77% / 1)'; // invokeBlue.200
static ANCHOR_CORNER_RADIUS_RATIO = 0.5;
static ANCHOR_STROKE_WIDTH = 2;
static ANCHOR_HIT_PADDING = 10;
static RESIZE_ANCHOR_SIZE = 8;
static ROTATE_ANCHOR_FILL_COLOR = 'hsl(200 76% 95% / 1)'; // invokeBlue.50
static ROTATE_ANCHOR_STROKE_COLOR = 'hsl(200 76% 40% / 1)'; // invokeBlue.700
static ROTATE_ANCHOR_SIZE = 12;
id: string;
path: string[];
parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter;
manager: CanvasManager;
log: Logger;
config: CanvasEntityTransformerConfig = DEFAULT_CONFIG;
/**
* The rect of the parent, _including_ transparent regions.
* It is calculated via Konva's getClientRect method, which is fast but includes transparent regions.
*
* Stored as a nanostores atom for easy reactivity.
*/
$nodeRect = atom<Rect>(getEmptyRect());
nodeRect = getEmptyRect();
/**
* The rect of the parent, _excluding_ transparent regions.
* If the parent's nodes have no possibility of transparent regions, this will be calculated the same way as nodeRect.
* If the parent's nodes may have transparent regions, this will be calculated manually by rasterizing the parent and
* checking the pixel data.
*
* Stored as a nanostores atom for easy reactivity.
*/
$pixelRect = atom<Rect>(getEmptyRect());
pixelRect = getEmptyRect();
/**
* Whether the transformer is currently calculating the rect of the parent.
*
* Stored as a nanostores atom for easy reactivity.
*/
$isPendingRectCalculation = atom<boolean>(true);
isPendingRectCalculation: boolean = true;
/**
* A set of subscriptions that should be cleaned up when the transformer is destroyed.
@@ -119,40 +69,27 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
/**
* Whether the transformer is currently transforming the entity.
*
* Stored as a nanostores atom for easy reactivity.
*/
$isTransforming = atom<boolean>(false);
isTransforming: boolean = false;
/**
* The current interaction mode of the transformer:
* - 'all': The entity can be moved, resized, and rotated.
* - 'drag': The entity can be moved.
* - 'off': The transformer is not interactable.
*
* Stored as a nanostores atom for easy reactivity.
*/
$interactionMode = atom<'all' | 'drag' | 'off'>('off');
interactionMode: 'all' | 'drag' | 'off' = 'off';
/**
* Whether dragging is enabled. Dragging is enabled in both 'all' and 'drag' interaction modes.
*
* Stored as a nanostores atom for easy reactivity.
*/
$isDragEnabled = atom<boolean>(false);
isDragEnabled: boolean = false;
/**
* Whether transforming is enabled. Transforming is enabled only in 'all' interaction mode.
*
* Stored as a nanostores atom for easy reactivity.
*/
$isTransformEnabled = atom<boolean>(false);
isTransformEnabled: boolean = false;
/**
* Whether the transformer is currently processing (rasterizing and uploading) the transformed entity.
*
* Stored as a nanostores atom for easy reactivity.
*/
$isProcessing = atom(false);
konva: {
@@ -161,22 +98,21 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
outlineRect: Konva.Rect;
};
constructor(parent: CanvasEntityTransformer['parent']) {
constructor(parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating entity transformer module');
this.konva = {
outlineRect: new Konva.Rect({
listening: false,
draggable: false,
name: `${this.type}:outline_rect`,
stroke: this.config.OUTLINE_COLOR,
stroke: CanvasEntityTransformer.OUTLINE_COLOR,
perfectDrawEnabled: false,
strokeHitEnabled: false,
}),
@@ -192,18 +128,111 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
// Transforming will retain aspect ratio only when shift is held
keepRatio: false,
// The padding is the distance between the transformer bbox and the nodes
padding: this.config.OUTLINE_PADDING,
padding: CanvasEntityTransformer.OUTLINE_PADDING,
// This is `invokeBlue.400`
stroke: this.config.OUTLINE_COLOR,
anchorFill: this.config.SCALE_ANCHOR_FILL_COLOR,
anchorStroke: this.config.SCALE_ANCHOR_STROKE_COLOR,
anchorStrokeWidth: this.config.SCALE_ANCHOR_STROKE_WIDTH,
anchorSize: this.config.SCALE_ANCHOR_SIZE,
anchorCornerRadius: this.config.SCALE_ANCHOR_SIZE * this.config.SCALE_ANCHOR_CORNER_RADIUS_RATIO,
stroke: CanvasEntityTransformer.OUTLINE_COLOR,
anchorFill: CanvasEntityTransformer.ANCHOR_FILL_COLOR,
anchorStroke: CanvasEntityTransformer.ANCHOR_STROKE_COLOR,
anchorStrokeWidth: CanvasEntityTransformer.ANCHOR_STROKE_WIDTH,
anchorSize: CanvasEntityTransformer.RESIZE_ANCHOR_SIZE,
anchorCornerRadius:
CanvasEntityTransformer.RESIZE_ANCHOR_SIZE * CanvasEntityTransformer.ANCHOR_CORNER_RADIUS_RATIO,
// This function is called for each anchor to style it (and do anything else you might want to do).
anchorStyleFunc: this.anchorStyleFunc,
anchorDragBoundFunc: this.anchorDragBoundFunc,
boundBoxFunc: this.boxBoundFunc,
anchorStyleFunc: (anchor) => {
// Give the rotater special styling
if (anchor.hasName('rotater')) {
anchor.setAttrs({
height: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE,
width: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE,
cornerRadius:
CanvasEntityTransformer.ROTATE_ANCHOR_SIZE * CanvasEntityTransformer.ANCHOR_CORNER_RADIUS_RATIO,
fill: CanvasEntityTransformer.ROTATE_ANCHOR_FILL_COLOR,
stroke: CanvasEntityTransformer.ANCHOR_FILL_COLOR,
offsetX: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE / 2,
offsetY: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE / 2,
});
}
// Add some padding to the hit area of the anchors
anchor.hitFunc((context) => {
context.beginPath();
context.rect(
-CanvasEntityTransformer.ANCHOR_HIT_PADDING,
-CanvasEntityTransformer.ANCHOR_HIT_PADDING,
anchor.width() + CanvasEntityTransformer.ANCHOR_HIT_PADDING * 2,
anchor.height() + CanvasEntityTransformer.ANCHOR_HIT_PADDING * 2
);
context.closePath();
context.fillStrokeShape(anchor);
});
},
anchorDragBoundFunc: (oldPos: Coordinate, newPos: Coordinate) => {
// The anchorDragBoundFunc callback puts constraints on the movement of the transformer anchors, which in
// turn constrain the transformation. It is called on every anchor move. We'll use this to snap the anchors
// to the nearest pixel.
// If we are rotating, no need to do anything - just let the rotation happen.
if (this.konva.transformer.getActiveAnchor() === 'rotater') {
return newPos;
}
// We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute,
// scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute
// before returning them.
const stageScale = this.manager.stage.getScale();
const stagePos = this.manager.stage.getPosition();
// Unscale and round the target position to the nearest pixel.
const targetX = Math.round(newPos.x / stageScale);
const targetY = Math.round(newPos.y / stageScale);
// The stage may be offset a fraction of a pixel. To ensure the anchor snaps to the nearest pixel, we need to
// calculate that offset and add it back to the target position.
// Calculate the offset. It's the remainder of the stage position divided by the scale * desired grid size. In
// this case, the grid size is 1px. For example, if we wanted to snap to the nearest 8px, the calculation would
// be `stagePos.x % (stageScale * 8)`.
const scaledOffsetX = stagePos.x % stageScale;
const scaledOffsetY = stagePos.y % stageScale;
// Unscale the target position and add the offset to get the absolute position for this anchor.
const scaledTargetX = targetX * stageScale + scaledOffsetX;
const scaledTargetY = targetY * stageScale + scaledOffsetY;
this.log.trace(
{
oldPos,
newPos,
stageScale,
stagePos,
targetX,
targetY,
scaledOffsetX,
scaledOffsetY,
scaledTargetX,
scaledTargetY,
},
'Anchor drag bound'
);
return { x: scaledTargetX, y: scaledTargetY };
},
boundBoxFunc: (oldBoundBox, newBoundBox) => {
// Bail if we are not rotating, we don't need to do anything.
if (this.konva.transformer.getActiveAnchor() !== 'rotater') {
return newBoundBox;
}
// This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and
// height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to
// the nearest 45 degrees when shift is held.
if (this.manager.stateApi.$shiftKey.get()) {
if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) {
return oldBoundBox;
}
}
return newBoundBox;
},
}),
proxyRect: new Konva.Rect({
name: `${this.type}:proxy_rect`,
@@ -212,15 +241,129 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
}),
};
this.konva.transformer.on('transform', this.syncObjectGroupWithProxyRect);
this.konva.transformer.on('transformend', this.snapProxyRectToPixelGrid);
this.konva.proxyRect.on('dragmove', this.onDragMove);
this.konva.proxyRect.on('dragend', this.onDragEnd);
this.konva.transformer.on('transformstart', () => {
// Just logging in this callback. Called on mouse down of a transform anchor.
this.log.trace(
{
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
scaleX: this.konva.proxyRect.scaleX(),
scaleY: this.konva.proxyRect.scaleY(),
rotation: this.konva.proxyRect.rotation(),
},
'Transform started'
);
});
this.konva.transformer.on('transform', () => {
// This is called when a transform anchor is dragged. By this time, the transform constraints in the above
// callbacks have been enforced, and the transformer has updated its nodes' attributes. We need to pass the
// updated attributes to the object group, propagating the transformation on down.
this.syncObjectGroupWithProxyRect();
});
this.konva.transformer.on('transformend', () => {
// Called on mouse up on an anchor. We'll do some final snapping to ensure the transformer is pixel-perfect.
// Snap the position to the nearest pixel.
const x = this.konva.proxyRect.x();
const y = this.konva.proxyRect.y();
const snappedX = Math.round(x);
const snappedY = Math.round(y);
// The transformer doesn't modify the width and height. It only modifies scale. We'll need to apply the scale to
// the width and height, round them to the nearest pixel, and finally calculate a new scale that will result in
// the snapped width and height.
const width = this.konva.proxyRect.width();
const height = this.konva.proxyRect.height();
const scaleX = this.konva.proxyRect.scaleX();
const scaleY = this.konva.proxyRect.scaleY();
// Determine the target width and height, rounded to the nearest pixel. Must be >= 1. Because the scales can be
// negative, we need to take the absolute value of the width and height.
const targetWidth = Math.max(Math.abs(Math.round(width * scaleX)), 1);
const targetHeight = Math.max(Math.abs(Math.round(height * scaleY)), 1);
// Calculate the scale we need to use to get the target width and height. Restore the sign of the scales.
const snappedScaleX = (targetWidth / width) * Math.sign(scaleX);
const snappedScaleY = (targetHeight / height) * Math.sign(scaleY);
// Update interaction rect and object group attributes.
this.konva.proxyRect.setAttrs({
x: snappedX,
y: snappedY,
scaleX: snappedScaleX,
scaleY: snappedScaleY,
});
this.parent.renderer.konva.objectGroup.setAttrs({
x: snappedX,
y: snappedY,
scaleX: snappedScaleX,
scaleY: snappedScaleY,
});
// Rotation is only retrieved for logging purposes.
const rotation = this.konva.proxyRect.rotation();
this.log.trace(
{
x,
y,
width,
height,
scaleX,
scaleY,
rotation,
snappedX,
snappedY,
targetWidth,
targetHeight,
snappedScaleX,
snappedScaleY,
},
'Transform ended'
);
});
this.konva.proxyRect.on('dragmove', () => {
// Snap the interaction rect to the nearest pixel
this.konva.proxyRect.x(Math.round(this.konva.proxyRect.x()));
this.konva.proxyRect.y(Math.round(this.konva.proxyRect.y()));
// The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
// and border
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING),
y: this.konva.proxyRect.y() - this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING),
});
// The object group is translated by the difference between the interaction rect's new and old positions (which is
// stored as this.pixelRect)
this.parent.renderer.konva.objectGroup.setAttrs({
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
});
});
this.konva.proxyRect.on('dragend', () => {
if (this.isTransforming) {
// If we are transforming the entity, we should not push the new position to the state. This will trigger a
// re-render of the entity and bork the transformation.
return;
}
const position = {
x: this.konva.proxyRect.x() - this.pixelRect.x,
y: this.konva.proxyRect.y() - this.pixelRect.y,
};
this.log.trace({ position }, 'Position changed');
this.manager.stateApi.setEntityPosition({ entityIdentifier: this.parent.getEntityIdentifier(), position });
});
// When the stage scale changes, we may need to re-scale some of the transformer's components. For example,
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
this.subscriptions.add(
this.manager.stage.$stageAttrs.listen((newVal, oldVal) => {
// When the stage scale changes, we may need to re-scale some of the transformer's components. For example,
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
this.manager.stateApi.$stageAttrs.listen((newVal, oldVal) => {
if (newVal.scale !== oldVal.scale) {
this.syncScale();
}
@@ -236,7 +379,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
);
// When the selected tool changes, we need to update the transformer's interaction state.
this.subscriptions.add(this.manager.tool.$tool.listen(this.syncInteractionState));
this.subscriptions.add(this.manager.stateApi.$tool.listen(this.syncInteractionState));
// When the selected entity changes, we need to update the transformer's interaction state.
this.subscriptions.add(this.manager.stateApi.$selectedEntityIdentifier.listen(this.syncInteractionState));
@@ -246,182 +389,6 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
this.parent.konva.layer.add(this.konva.transformer);
}
anchorStyleFunc = (anchor: Konva.Rect): void => {
// Give the rotater special styling
if (anchor.hasName('rotater')) {
anchor.setAttrs({
height: this.config.ROTATE_ANCHOR_SIZE,
width: this.config.ROTATE_ANCHOR_SIZE,
cornerRadius: this.config.ROTATE_ANCHOR_SIZE * this.config.SCALE_ANCHOR_CORNER_RADIUS_RATIO,
fill: this.config.ROTATE_ANCHOR_FILL_COLOR,
stroke: this.config.SCALE_ANCHOR_FILL_COLOR,
offsetX: this.config.ROTATE_ANCHOR_SIZE / 2,
offsetY: this.config.ROTATE_ANCHOR_SIZE / 2,
});
}
// Add some padding to the hit area of the anchors
anchor.hitFunc((context) => {
context.beginPath();
context.rect(
-this.config.ANCHOR_HIT_PADDING,
-this.config.ANCHOR_HIT_PADDING,
anchor.width() + this.config.ANCHOR_HIT_PADDING * 2,
anchor.height() + this.config.ANCHOR_HIT_PADDING * 2
);
context.closePath();
context.fillStrokeShape(anchor);
});
};
anchorDragBoundFunc = (oldPos: Coordinate, newPos: Coordinate) => {
// The anchorDragBoundFunc callback puts constraints on the movement of the transformer anchors, which in
// turn constrain the transformation. It is called on every anchor move. We'll use this to snap the anchors
// to the nearest pixel.
// If we are rotating, no need to do anything - just let the rotation happen.
if (this.konva.transformer.getActiveAnchor() === 'rotater') {
return newPos;
}
// We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute,
// scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute
// before returning them.
const stageScale = this.manager.stage.getScale();
const stagePos = this.manager.stage.getPosition();
// Unscale and round the target position to the nearest pixel.
const targetX = Math.round(newPos.x / stageScale);
const targetY = Math.round(newPos.y / stageScale);
// The stage may be offset a fraction of a pixel. To ensure the anchor snaps to the nearest pixel, we need to
// calculate that offset and add it back to the target position.
// Calculate the offset. It's the remainder of the stage position divided by the scale * desired grid size. In
// this case, the grid size is 1px. For example, if we wanted to snap to the nearest 8px, the calculation would
// be `stagePos.x % (stageScale * 8)`.
const scaledOffsetX = stagePos.x % stageScale;
const scaledOffsetY = stagePos.y % stageScale;
// Unscale the target position and add the offset to get the absolute position for this anchor.
const scaledTargetX = targetX * stageScale + scaledOffsetX;
const scaledTargetY = targetY * stageScale + scaledOffsetY;
return { x: scaledTargetX, y: scaledTargetY };
};
boxBoundFunc = (oldBoundBox: RectWithRotation, newBoundBox: RectWithRotation) => {
// Bail if we are not rotating, we don't need to do anything.
if (this.konva.transformer.getActiveAnchor() !== 'rotater') {
return newBoundBox;
}
// This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and
// height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to
// the nearest 45 degrees when shift is held.
if (this.manager.stateApi.$shiftKey.get()) {
if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) {
return oldBoundBox;
}
}
return newBoundBox;
};
/**
* Snaps the proxy rect to the nearest pixel, syncing the object group with the proxy rect.
*/
snapProxyRectToPixelGrid = () => {
// Called on mouse up on an anchor. We'll do some final snapping to ensure the transformer is pixel-perfect.
// Snap the position to the nearest pixel.
const x = this.konva.proxyRect.x();
const y = this.konva.proxyRect.y();
const snappedX = Math.round(x);
const snappedY = Math.round(y);
// The transformer doesn't modify the width and height. It only modifies scale. We'll need to apply the scale to
// the width and height, round them to the nearest pixel, and finally calculate a new scale that will result in
// the snapped width and height.
const width = this.konva.proxyRect.width();
const height = this.konva.proxyRect.height();
const scaleX = this.konva.proxyRect.scaleX();
const scaleY = this.konva.proxyRect.scaleY();
// Determine the target width and height, rounded to the nearest pixel. Must be >= 1. Because the scales can be
// negative, we need to take the absolute value of the width and height.
const targetWidth = Math.max(Math.abs(Math.round(width * scaleX)), 1);
const targetHeight = Math.max(Math.abs(Math.round(height * scaleY)), 1);
// Calculate the scale we need to use to get the target width and height. Restore the sign of the scales.
const snappedScaleX = (targetWidth / width) * Math.sign(scaleX);
const snappedScaleY = (targetHeight / height) * Math.sign(scaleY);
// Update interaction rect and object group attributes.
this.konva.proxyRect.setAttrs({
x: snappedX,
y: snappedY,
scaleX: snappedScaleX,
scaleY: snappedScaleY,
});
this.syncObjectGroupWithProxyRect();
};
/**
* Fits the proxy rect to the bounding box of the parent entity, then syncs the object group with the proxy rect.
*/
fitProxyRectToBbox = () => {
const { rect } = this.manager.stateApi.getBbox();
const scaleX = rect.width / this.konva.proxyRect.width();
const scaleY = rect.height / this.konva.proxyRect.height();
this.konva.proxyRect.setAttrs({
x: rect.x,
y: rect.y,
scaleX,
scaleY,
rotation: 0,
});
this.syncObjectGroupWithProxyRect();
};
onDragMove = () => {
// Snap the interaction rect to the nearest pixel
this.konva.proxyRect.x(Math.round(this.konva.proxyRect.x()));
this.konva.proxyRect.y(Math.round(this.konva.proxyRect.y()));
// The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
// and border
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING),
y: this.konva.proxyRect.y() - this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING),
});
// The object group is translated by the difference between the interaction rect's new and old positions (which is
// stored as this.pixelRect)
this.parent.renderer.konva.objectGroup.setAttrs({
x: this.konva.proxyRect.x(),
y: this.konva.proxyRect.y(),
});
};
onDragEnd = () => {
if (this.$isTransforming.get()) {
// If we are transforming the entity, we should not push the new position to the state. This will trigger a
// re-render of the entity and bork the transformation.
return;
}
const pixelRect = this.$pixelRect.get();
const position = {
x: this.konva.proxyRect.x() - pixelRect.x,
y: this.konva.proxyRect.y() - pixelRect.y,
};
this.log.trace({ position }, 'Position changed');
this.manager.stateApi.setEntityPosition({ entityIdentifier: this.parent.getEntityIdentifier(), position });
};
// TODO(psyche): These don't work when the entity is rotated, need to do some math to offset the flip after rotation
// flipHorizontal = () => {
// if (!this.isTransforming || this.$isProcessing.get()) {
@@ -477,7 +444,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
*/
update = (position: Coordinate, bbox: Rect) => {
const onePixel = this.manager.stage.getScaledPixels(1);
const bboxPadding = this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING);
const bboxPadding = this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING);
this.konva.outlineRect.setAttrs({
x: position.x + bbox.x - bboxPadding,
@@ -501,17 +468,14 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
syncInteractionState = () => {
this.log.trace('Syncing interaction state');
const pixelRect = this.$pixelRect.get();
const isPendingRectCalculation = this.$isPendingRectCalculation.get();
if (isPendingRectCalculation || pixelRect.width === 0 || pixelRect.height === 0) {
if (this.isPendingRectCalculation || this.pixelRect.width === 0 || this.pixelRect.height === 0) {
// If the rect is being calculated, or if the rect has no width or height, we can't interact with the transformer
this.parent.konva.layer.listening(false);
this.setInteractionMode('off');
return;
}
const tool = this.manager.tool.$tool.get();
const tool = this.manager.stateApi.$tool.get();
const isSelected = this.manager.stateApi.getIsSelected(this.parent.id);
if (!this.parent.renderer.hasObjects() || this.parent.state.isLocked || !this.parent.state.isEnabled) {
@@ -521,11 +485,11 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
return;
}
if (isSelected && !this.$isTransforming.get() && tool === 'move') {
if (isSelected && !this.isTransforming && tool === 'move') {
// We are moving this layer, it must be listening
this.parent.konva.layer.listening(true);
this.setInteractionMode('drag');
} else if (isSelected && this.$isTransforming.get()) {
} else if (isSelected && this.isTransforming) {
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is
// active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected.
if (tool !== 'view') {
@@ -547,7 +511,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
*/
syncScale = () => {
const onePixel = this.manager.stage.getScaledPixels(1);
const bboxPadding = this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING);
const bboxPadding = this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING);
this.konva.outlineRect.setAttrs({
x: this.konva.proxyRect.x() - bboxPadding,
@@ -564,16 +528,16 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
*/
startTransform = () => {
this.log.debug('Starting transform');
this.$isTransforming.set(true);
this.manager.tool.$tool.set('move');
this.isTransforming = true;
this.manager.stateApi.$tool.set('move');
// 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
// TODO(psyche): We just set the tool to 'move', why would it be 'view'? Investigate and figure out if this is needed
const shouldListen = this.manager.tool.$tool.get() !== 'view';
const shouldListen = this.manager.stateApi.$tool.get() !== 'view';
this.parent.konva.layer.listening(shouldListen);
this.setInteractionMode('all');
this.manager.stateApi.$transformingAdapter.set(this.parent);
this.manager.stateApi.$transformingEntity.set(this.parent.getEntityIdentifier());
};
/**
@@ -583,7 +547,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
this.log.debug('Applying transform');
this.$isProcessing.set(true);
const rect = this.getRelativeRect();
await this.parent.renderer.rasterize({ rect, replaceObjects: true, attrs: { filters: [] } });
await this.parent.renderer.rasterize({ rect, replaceObjects: true });
this.requestRectCalculation();
this.stopTransform();
};
@@ -601,14 +565,14 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
stopTransform = () => {
this.log.debug('Stopping transform');
this.$isTransforming.set(false);
this.isTransforming = false;
this.setInteractionMode('off');
// Reset the transform of the the entity. We've either replaced the transformed objects with a rasterized image, or
// canceled a transformation. In either case, the scale should be reset.
this.resetTransform();
this.syncInteractionState();
this.manager.stateApi.$transformingAdapter.set(null);
this.manager.stateApi.$transformingEntity.set(null);
this.$isProcessing.set(false);
};
@@ -637,17 +601,16 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
this.log.trace('Updating position');
const position = get(arg, 'position', this.parent.state.position);
const pixelRect = this.$pixelRect.get();
const groupAttrs: Partial<GroupConfig> = {
x: position.x + pixelRect.x,
y: position.y + pixelRect.y,
offsetX: pixelRect.x,
offsetY: pixelRect.y,
x: position.x + this.pixelRect.x,
y: position.y + this.pixelRect.y,
offsetX: this.pixelRect.x,
offsetY: this.pixelRect.y,
};
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
this.update(position, pixelRect);
this.update(position, this.pixelRect);
};
/**
@@ -658,7 +621,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
* - 'off': The transformer is not interactable.
*/
setInteractionMode = (interactionMode: 'all' | 'drag' | 'off') => {
this.$interactionMode.set(interactionMode);
this.interactionMode = interactionMode;
if (interactionMode === 'drag') {
this._enableDrag();
this._disableTransform();
@@ -675,19 +638,16 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
};
updateBbox = () => {
const nodeRect = this.$nodeRect.get();
const pixelRect = this.$pixelRect.get();
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect }, 'Updating bbox');
this.log.trace({ nodeRect, pixelRect }, 'Updating bbox');
if (this.$isPendingRectCalculation.get()) {
if (this.isPendingRectCalculation) {
this.syncInteractionState();
return;
}
// If the bbox has no width or height, that means the layer is fully transparent. This can happen if it is only
// eraser lines, fully clipped brush lines or if it has been fully erased.
if (pixelRect.width === 0 || pixelRect.height === 0) {
if (this.pixelRect.width === 0 || this.pixelRect.height === 0) {
// If the layer already has no objects, we don't need to reset the entity state. This would cause a push to the
// undo stack and clear the redo stack.
if (this.parent.renderer.hasObjects()) {
@@ -696,12 +656,12 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
}
} else {
this.syncInteractionState();
this.update(this.parent.state.position, pixelRect);
this.update(this.parent.state.position, this.pixelRect);
const groupAttrs: Partial<GroupConfig> = {
x: this.parent.state.position.x + pixelRect.x,
y: this.parent.state.position.y + pixelRect.y,
offsetX: pixelRect.x,
offsetY: pixelRect.y,
x: this.parent.state.position.x + this.pixelRect.x,
y: this.parent.state.position.y + this.pixelRect.y,
offsetX: this.pixelRect.x,
offsetY: this.pixelRect.y,
};
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
@@ -713,13 +673,13 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
calculateRect = debounce(() => {
this.log.debug('Calculating bbox');
this.$isPendingRectCalculation.set(true);
this.isPendingRectCalculation = true;
if (!this.parent.renderer.hasObjects()) {
this.log.trace('No objects, resetting bbox');
this.$nodeRect.set(getEmptyRect());
this.$pixelRect.set(getEmptyRect());
this.$isPendingRectCalculation.set(false);
this.nodeRect = getEmptyRect();
this.pixelRect = getEmptyRect();
this.isPendingRectCalculation = false;
this.updateBbox();
return;
}
@@ -727,10 +687,10 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
const rect = this.parent.renderer.konva.objectGroup.getClientRect({ skipTransform: true });
if (!this.parent.renderer.needsPixelBbox()) {
this.$nodeRect.set({ ...rect });
this.$pixelRect.set({ ...rect });
this.log.trace({ nodeRect: this.$nodeRect.get(), pixelRect: this.$pixelRect.get() }, 'Got bbox from client rect');
this.$isPendingRectCalculation.set(false);
this.nodeRect = { ...rect };
this.pixelRect = { ...rect };
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect }, 'Got bbox from client rect');
this.isPendingRectCalculation = false;
this.updateBbox();
return;
}
@@ -743,29 +703,26 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
(extents) => {
if (extents) {
const { minX, minY, maxX, maxY } = extents;
this.$nodeRect.set({ ...rect });
this.$pixelRect.set({
this.nodeRect = { ...rect };
this.pixelRect = {
x: Math.round(rect.x) + minX,
y: Math.round(rect.y) + minY,
width: maxX - minX,
height: maxY - minY,
});
};
} else {
this.$nodeRect.set(getEmptyRect());
this.$pixelRect.set(getEmptyRect());
this.nodeRect = getEmptyRect();
this.pixelRect = getEmptyRect();
}
this.log.trace(
{ nodeRect: this.$nodeRect.get(), pixelRect: this.$pixelRect.get(), extents },
`Got bbox from worker`
);
this.$isPendingRectCalculation.set(false);
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect, extents }, `Got bbox from worker`);
this.isPendingRectCalculation = false;
this.updateBbox();
}
);
}, this.config.RECT_CALC_DEBOUNCE_MS);
}, CanvasEntityTransformer.RECT_CALC_DEBOUNCE_MS);
requestRectCalculation = () => {
this.$isPendingRectCalculation.set(true);
this.isPendingRectCalculation = true;
this.syncInteractionState();
this.calculateRect();
};
@@ -775,27 +732,27 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
};
_enableTransform = () => {
this.$isTransformEnabled.set(true);
this.isTransformEnabled = true;
this.konva.transformer.visible(true);
this.konva.transformer.listening(true);
this.konva.transformer.nodes([this.konva.proxyRect]);
};
_disableTransform = () => {
this.$isTransformEnabled.set(false);
this.isTransformEnabled = false;
this.konva.transformer.visible(false);
this.konva.transformer.listening(false);
this.konva.transformer.nodes([]);
};
_enableDrag = () => {
this.$isDragEnabled.set(true);
this.isDragEnabled = true;
this.konva.proxyRect.visible(true);
this.konva.proxyRect.listening(true);
};
_disableDrag = () => {
this.$isDragEnabled.set(false);
this.isDragEnabled = false;
this.konva.proxyRect.visible(false);
this.konva.proxyRect.listening(false);
};
@@ -808,27 +765,32 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
this.konva.outlineRect.visible(false);
};
/**
* Gets a JSON-serializable object that describes the transformer.
*/
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
nodeRect: this.$nodeRect.get(),
pixelRect: this.$pixelRect.get(),
isPendingRectCalculation: this.$isPendingRectCalculation.get(),
isTransforming: this.$isTransforming.get(),
interactionMode: this.$interactionMode.get(),
isDragEnabled: this.$isDragEnabled.get(),
isTransformEnabled: this.$isTransformEnabled.get(),
isProcessing: this.$isProcessing.get(),
mode: this.interactionMode,
isTransformEnabled: this.isTransformEnabled,
isDragEnabled: this.isDragEnabled,
};
};
/**
* Destroys the transformer, cleaning up any subscriptions.
*/
destroy = () => {
this.log.debug('Destroying module');
this.log.debug('Destroying entity transformer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.outlineRect.destroy();
this.konva.transformer.destroy();
this.konva.proxyRect.destroy();
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,134 +0,0 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type { Logger } from 'roarr';
type EraserToolPreviewConfig = {
/**
* The inner border color for the eraser tool preview.
*/
BORDER_INNER_COLOR: string;
/**
* The outer border color for the eraser tool preview.
*/
BORDER_OUTER_COLOR: string;
};
const DEFAULT_CONFIG: EraserToolPreviewConfig = {
BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
};
export class CanvasEraserToolPreview extends CanvasModuleBase {
readonly type = 'eraser_tool_preview';
id: string;
path: string[];
parent: CanvasToolModule;
manager: CanvasManager;
log: Logger;
config: EraserToolPreviewConfig = DEFAULT_CONFIG;
konva: {
group: Konva.Group;
cutoutCircle: Konva.Circle;
innerBorder: Konva.Ring;
outerBorder: Konva.Ring;
};
constructor(parent: CanvasToolModule) {
super();
this.id = getPrefixedId(this.type);
this.parent = parent;
this.manager = this.parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug('Creating module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:eraser_group`, listening: false }),
cutoutCircle: new Konva.Circle({
name: `${this.type}:eraser_cutout_circle`,
listening: false,
strokeEnabled: false,
// The fill is used only to erase what is underneath it, so its color doesn't matter - just needs to be opaque
fill: 'white',
globalCompositeOperation: 'destination-out',
}),
innerBorder: new Konva.Ring({
name: `${this.type}:eraser_inner_border_ring`,
listening: false,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_INNER_COLOR,
strokeEnabled: false,
}),
outerBorder: new Konva.Ring({
name: `${this.type}:eraser_outer_border_ring`,
innerRadius: 0,
outerRadius: 0,
fill: this.config.BORDER_OUTER_COLOR,
strokeEnabled: false,
}),
};
this.konva.group.add(this.konva.cutoutCircle, this.konva.innerBorder, this.konva.outerBorder);
}
render = () => {
const cursorPos = this.manager.tool.$lastCursorPos.get();
if (!cursorPos) {
return;
}
const toolState = this.manager.stateApi.getToolState();
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const radius = toolState.eraser.width / 2;
// The circle is scaled
this.konva.cutoutCircle.setAttrs({
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
});
// But the borders are in screen-pixels
const onePixel = this.manager.stage.getScaledPixels(1);
const twoPixels = this.manager.stage.getScaledPixels(2);
this.konva.innerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius,
outerRadius: radius + onePixel,
});
this.konva.outerBorder.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
};
setVisibility = (visible: boolean) => {
this.konva.group.visible(visible);
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
config: this.config,
};
};
destroy = () => {
this.log.debug('Destroying eraser tool preview module');
this.konva.group.destroy();
};
}

View File

@@ -1,7 +1,7 @@
import type { SerializableObject } from 'common/types';
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasEntityIdentifier, CanvasImageState, FilterConfig } from 'features/controlLayers/store/types';
import { IMAGE_FILTERS, imageDTOToImageObject } from 'features/controlLayers/store/types';
@@ -11,14 +11,14 @@ import { getImageDTO } from 'services/api/endpoints/images';
import type { BatchConfig, ImageDTO, S } from 'services/api/types';
import { assert } from 'tsafe';
export class CanvasFilterModule extends CanvasModuleBase {
export class CanvasFilterModule extends CanvasModuleABC {
readonly type = 'canvas_filter';
id: string;
path: string[];
parent: CanvasManager;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
imageState: CanvasImageState | null = null;
@@ -30,10 +30,9 @@ export class CanvasFilterModule extends CanvasModuleBase {
constructor(manager: CanvasManager) {
super();
this.id = getPrefixedId(this.type);
this.parent = manager;
this.manager = manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.manager.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug('Creating filter module');
}
@@ -50,7 +49,7 @@ export class CanvasFilterModule extends CanvasModuleBase {
return;
}
this.$adapter.set(entity.adapter);
this.manager.tool.$tool.set('view');
this.manager.stateApi.$tool.set('view');
};
previewFilter = async () => {
@@ -168,4 +167,21 @@ export class CanvasFilterModule extends CanvasModuleBase {
return batch;
};
destroy = () => {
this.log.trace('Destroying filter module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
getLoggingContext = () => {
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -3,36 +3,32 @@ import { logger } from 'app/logging/logger';
import type { AppStore } from 'app/store/store';
import type { SerializableObject } from 'common/types';
import { SyncableMap } from 'common/util/SyncableMap/SyncableMap';
import { CanvasBboxModule } from 'features/controlLayers/konva/CanvasBboxModule';
import { CanvasCacheModule } from 'features/controlLayers/konva/CanvasCacheModule';
import { CanvasCompositorModule } from 'features/controlLayers/konva/CanvasCompositorModule';
import { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasProgressImageModule } from 'features/controlLayers/konva/CanvasProgressImageModule';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import { CanvasRenderingModule } from 'features/controlLayers/konva/CanvasRenderingModule';
import { CanvasStageModule } from 'features/controlLayers/konva/CanvasStageModule';
import { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule';
import { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
import { CanvasWorkerModule } from 'features/controlLayers/konva/CanvasWorkerModule.js';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import type Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
import { CanvasBackgroundModule } from './CanvasBackgroundModule';
import type { CanvasEntityLayerAdapter } from './CanvasEntityLayerAdapter';
import type { CanvasEntityMaskAdapter } from './CanvasEntityMaskAdapter';
import { CanvasPreviewModule } from './CanvasPreviewModule';
import { CanvasStateApiModule } from './CanvasStateApiModule';
export const $canvasManager = atom<CanvasManager | null>(null);
export class CanvasManager extends CanvasModuleBase {
export class CanvasManager extends CanvasModuleABC {
readonly type = 'manager';
id: string;
path: string[];
manager: CanvasManager;
parent: CanvasManager;
log: Logger;
store: AppStore;
@@ -56,6 +52,7 @@ export class CanvasManager extends CanvasModuleBase {
};
stateApi: CanvasStateApiModule;
preview: CanvasPreviewModule;
background: CanvasBackgroundModule;
filter: CanvasFilterModule;
stage: CanvasStageModule;
@@ -63,14 +60,6 @@ export class CanvasManager extends CanvasModuleBase {
cache: CanvasCacheModule;
renderer: CanvasRenderingModule;
compositor: CanvasCompositorModule;
tool: CanvasToolModule;
bbox: CanvasBboxModule;
stagingArea: CanvasStagingAreaModule;
progressImage: CanvasProgressImageModule;
konva: {
previewLayer: Konva.Layer;
};
_isDebugging: boolean = false;
@@ -79,7 +68,6 @@ export class CanvasManager extends CanvasModuleBase {
this.id = getPrefixedId(this.type);
this.path = [this.id];
this.manager = this;
this.parent = this;
this.log = logger('canvas').child((message) => {
return {
...message,
@@ -99,28 +87,14 @@ export class CanvasManager extends CanvasModuleBase {
this.worker = new CanvasWorkerModule(this);
this.cache = new CanvasCacheModule(this);
this.renderer = new CanvasRenderingModule(this);
this.preview = new CanvasPreviewModule(this);
this.filter = new CanvasFilterModule(this);
this.compositor = new CanvasCompositorModule(this);
this.stage.addLayer(this.preview.getLayer());
this.background = new CanvasBackgroundModule(this);
this.stage.addLayer(this.background.konva.layer);
this.konva = {
previewLayer: new Konva.Layer({ listening: false, imageSmoothingEnabled: false }),
};
this.stage.addLayer(this.konva.previewLayer);
this.tool = new CanvasToolModule(this);
this.stagingArea = new CanvasStagingAreaModule(this);
this.progressImage = new CanvasProgressImageModule(this);
this.bbox = new CanvasBboxModule(this);
// Must add in this order for correct z-index
this.konva.previewLayer.add(this.stagingArea.konva.group);
this.konva.previewLayer.add(this.progressImage.konva.group);
this.konva.previewLayer.add(this.bbox.konva.group);
this.konva.previewLayer.add(this.tool.konva.group);
}
enableDebugging() {
@@ -136,7 +110,7 @@ export class CanvasManager extends CanvasModuleBase {
this.log.debug('Initializing canvas manager module');
// These atoms require the canvas manager to be set up before we can provide their initial values
this.stateApi.$transformingAdapter.set(null);
this.stateApi.$transformingEntity.set(null);
this.stateApi.$toolState.set(this.stateApi.getToolState());
this.stateApi.$selectedEntityIdentifier.set(this.stateApi.getCanvasState().selectedEntityIdentifier);
this.stateApi.$currentFill.set(this.stateApi.getCurrentFill());
@@ -147,28 +121,19 @@ export class CanvasManager extends CanvasModuleBase {
};
destroy = () => {
this.log.debug('Destroying module');
this.log.debug('Destroying canvas manager module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
for (const adapter of this.adapters.getAll()) {
adapter.destroy();
}
this.bbox.destroy();
this.stagingArea.destroy();
this.tool.destroy();
this.progressImage.destroy();
this.konva.previewLayer.destroy();
this.stateApi.destroy();
this.preview.destroy();
this.background.destroy();
this.filter.destroy();
this.worker.destroy();
this.renderer.destroy();
this.compositor.destroy();
this.stage.destroy();
$canvasManager.set(null);
};
@@ -187,10 +152,7 @@ export class CanvasManager extends CanvasModuleBase {
inpaintMasks: Array.from(this.adapters.inpaintMasks.values()).map((adapter) => adapter.repr()),
regionMasks: Array.from(this.adapters.regionMasks.values()).map((adapter) => adapter.repr()),
stateApi: this.stateApi.repr(),
bbox: this.bbox.repr(),
stagingArea: this.stagingArea.repr(),
tool: this.tool.repr(),
progressImage: this.progressImage.repr(),
preview: this.preview.repr(),
background: this.background.repr(),
filter: this.filter.repr(),
worker: this.worker.repr(),
@@ -200,19 +162,19 @@ export class CanvasManager extends CanvasModuleBase {
};
};
getLoggingContext = (): SerializableObject => ({ path: this.path });
buildPath = (canvasModule: CanvasModuleBase): string[] => {
return canvasModule.parent.path.concat(canvasModule.id);
getLoggingContext = (): SerializableObject => {
return {
path: this.path.join('.'),
};
};
buildLogger = (canvasModule: CanvasModuleBase): Logger => {
buildLogger = (getContext: () => SerializableObject): Logger => {
return this.log.child((message) => {
return {
...message,
context: {
...message.context,
...canvasModule.getLoggingContext(),
...getContext(),
},
};
});

View File

@@ -0,0 +1,20 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { Logger } from 'roarr';
export abstract class CanvasModuleABC {
abstract id: string;
abstract type: string;
abstract path: string[];
abstract manager: CanvasManager;
abstract log: Logger;
abstract subscriptions: Set<() => void>;
abstract getLoggingContext: () => SerializableObject;
abstract destroy: () => void;
abstract repr: () => SerializableObject & {
id: string;
path: string[];
type: string;
};
}

View File

@@ -1,133 +0,0 @@
import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { Logger } from 'roarr';
export abstract class CanvasModuleBase {
/**
* The unique identifier of the module.
*
* If the module is associated with an entity, this should be the entity's id. Otherwise, the id should be based on
* the module's type. The `getPrefixedId` utility should be used for generating ids.
*
* @example
* ```ts
* this.id = getPrefixedId(this.type);
* // this.id -> "raster_layer:aS2NREsrlz"
* ```
*/
abstract id: string;
/**
* The type of the module.
*/
abstract type: string;
/**
* The path of the module in the canvas module tree.
*
* Modules should use the manager's `buildPath` method to set this value.
*
* @example
* ```ts
* this.path = this.manager.buildPath(this);
* // this.path -> ["manager:3PWJWmHbou", "raster_layer:aS2NREsrlz", "entity_renderer:sfLO4j1B0n", "brush_line:Zrsu8gpZMd"]
* ```
*/
abstract path: string[];
/**
* The canvas manager.
*/
abstract manager: CanvasManager;
/**
* The parent module. This may be the canvas manager or another module.
*/
abstract parent: CanvasModuleBase;
/**
* The logger for the module. The logger must be a `ROARR` logger.
*
* Modules should use the manager's `buildLogger` method to set this value.
*
* @example
* ```ts
* this.log = this.manager.buildLogger(this);
* ```
*/
abstract log: Logger;
/**
* Returns a logging context object that includes relevant information about the module.
* Canvas modules may override this method to include additional information in the logging context, but should
* always include the parent's logging context.
*
* The default implementation includes the parent context and the module's path.
*
* @example
* ```ts
* getLoggingContext = () => {
* return {
* ...this.parent.getLoggingContext(),
* path: this.path,
* someImportantValue: this.someImportantValue,
* };
* };
* ```
*/
getLoggingContext: () => SerializableObject = () => {
return {
...this.parent.getLoggingContext(),
path: this.path,
};
};
/**
* Cleans up the module when it is disposed.
*
* Canvas modules may override this method to clean up any loose ends. For example:
* - Destroy Konva nodes
* - Unsubscribe from any subscriptions
* - Abort async operations
* - Close websockets
* - Terminate workers
*
* This method is called when the module is disposed. For example:
* - When an entity is deleted and its module is destroyed
* - When the canvas manager is destroyed
*
* The default implementation only logs a message.
*
* @example
* ```ts
* destroy = () => {
* this.log('Destroying module');
* this.subscriptions.forEach((unsubscribe) => unsubscribe());
* this.konva.group.destroy();
* };
* ```
*/
destroy: () => void = () => {
this.log('Destroying module');
};
/**
* Returns a serializable representation of the module.
* Canvas modules may override this method to include additional information in the representation.
* The default implementation includes id, type, and path.
*
* @example
* ```ts
* repr = () => {
* return {
* id: this.id,
* type: this.type,
* path: this.path,
* state: deepClone(this.state),
* };
* };
* ```
*/
repr: () => SerializableObject = () => {
return {
id: this.id,
type: this.type,
path: this.path,
};
};
}

View File

@@ -2,12 +2,12 @@ import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import type { CanvasBrushLineState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
export class CanvasObjectBrushLineRenderer extends CanvasModuleABC {
readonly type = 'object_brush_line_renderer';
id: string;
@@ -15,6 +15,7 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
parent: CanvasEntityRenderer;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasBrushLineState;
konva: {
@@ -28,10 +29,10 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
this.id = id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug({ state }, 'Creating module');
this.log.debug({ state }, 'Creating brush line renderer module');
this.konva = {
group: new Konva.Group({
@@ -70,16 +71,17 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
return false;
}
destroy = () => {
this.log.debug('Destroying brush line renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting brush line visibility');
this.konva.group.visible(isVisible);
}
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
repr = () => {
return {
id: this.id,
@@ -89,4 +91,8 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
state: deepClone(this.state),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -1,12 +1,12 @@
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import type { CanvasEraserLineState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
export class CanvasObjectEraserLineRenderer extends CanvasModuleABC {
readonly type = 'object_eraser_line_renderer';
id: string;
@@ -14,6 +14,7 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
parent: CanvasEntityRenderer;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasEraserLineState;
konva: {
@@ -23,18 +24,19 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
constructor(state: CanvasEraserLineState, parent: CanvasEntityRenderer) {
super();
this.id = state.id;
const { id, clip } = state;
this.id = id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug({ state }, 'Creating eraser line renderer module');
this.konva = {
group: new Konva.Group({
name: `${this.type}:group`,
clip: state.clip,
clip,
listening: false,
}),
line: new Konva.Line({
@@ -68,16 +70,17 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
return false;
}
destroy = () => {
this.log.debug('Destroying eraser line renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting brush line visibility');
this.konva.group.visible(isVisible);
}
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
repr = () => {
return {
id: this.id,
@@ -87,4 +90,8 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
state: deepClone(this.state),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

View File

@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
import type { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
import type { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule';
import { loadImage } from 'features/controlLayers/konva/util';
import type { CanvasImageState } from 'features/controlLayers/store/types';
@@ -12,7 +12,7 @@ import Konva from 'konva';
import type { Logger } from 'roarr';
import { getImageDTO } from 'services/api/endpoints/images';
export class CanvasObjectImageRenderer extends CanvasModuleBase {
export class CanvasObjectImageRenderer extends CanvasModuleABC {
readonly type = 'object_image_renderer';
id: string;
@@ -20,6 +20,7 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
parent: CanvasEntityRenderer | CanvasStagingAreaModule | CanvasFilterModule;
manager: CanvasManager;
log: Logger;
subscriptions = new Set<() => void>();
state: CanvasImageState;
konva: {
@@ -34,15 +35,15 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
constructor(state: CanvasImageState, parent: CanvasEntityRenderer | CanvasStagingAreaModule | CanvasFilterModule) {
super();
this.id = state.id;
const { id, image } = state;
const { width, height } = image;
this.id = id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.path = this.parent.path.concat(this.id);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.log.debug({ state }, 'Creating module');
const { width, height } = state.image;
this.log.debug({ state }, 'Creating image renderer module');
this.konva = {
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
@@ -168,6 +169,7 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
destroy = () => {
this.log.debug('Destroying image renderer module');
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.konva.group.destroy();
};
@@ -187,4 +189,8 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
state: deepClone(this.state),
};
};
getLoggingContext = () => {
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
};
}

Some files were not shown because too many files have changed in this diff Show More