Compare commits

...

175 Commits

Author SHA1 Message Date
psychedelicious
0d67ee6548 tests(ui): fix logging mock 2025-07-09 23:15:25 +10:00
psychedelicious
03c21d1607 fix(ui): gallery not updating when saving staging area image 2025-07-09 23:15:25 +10:00
psychedelicious
752e8db1f5 tidy(ui): demote logging in nav api to trace 2025-07-09 23:15:25 +10:00
psychedelicious
85fc861dd9 chore(ui): lint 2025-07-09 23:15:25 +10:00
psychedelicious
458cbfd874 fix(ui): selected model not highlighted 2025-07-09 23:15:25 +10:00
psychedelicious
04331c070a fix(ui): set denoise w/h when running flux fill 2025-07-09 23:15:25 +10:00
psychedelicious
632ddf0cb4 tests(ui): update tests for navigation api 2025-07-09 23:15:25 +10:00
psychedelicious
2b193ff416 fix(ui): delete stored state on error & save new state 2025-07-09 23:15:25 +10:00
psychedelicious
96ee394f9e refactor(ui): use dockview's own ser/de for persistence 2025-07-09 23:15:25 +10:00
psychedelicious
0badc80c0c fix(ui): ignore disabled ref images in readiness checks 2025-07-09 23:15:25 +10:00
psychedelicious
78e6cbf96e fix(ui): default tab is generate 2025-07-09 23:15:25 +10:00
psychedelicious
0b969a661b fix(ui): remove dep on focus from useDeleteImage 2025-07-09 23:15:25 +10:00
psychedelicious
6fe47ec9f8 feat(ui): improve ref image model autoswitch logic 2025-07-09 23:15:25 +10:00
Kent Keirsey
3850dd61f8 update comment 2025-07-09 23:15:25 +10:00
Kent Keirsey
75520eaf0f Match Chatgpt4o and kontext names exactly 2025-07-09 23:15:25 +10:00
Kent Keirsey
10e88c58c1 fix and lint 2025-07-09 23:15:25 +10:00
Kent Keirsey
30ed4dbd92 lint 2025-07-09 23:15:25 +10:00
Kent Keirsey
ed9c090f33 fixes 2025-07-09 23:15:25 +10:00
Kent Keirsey
d29f65ed22 lint fixes 2025-07-09 23:15:25 +10:00
Kent Keirsey
2062ec8ac0 Update invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts
Co-authored-by: Mary Hipp Rogers <maryhipp@gmail.com>
2025-07-09 23:15:25 +10:00
Cursor Agent
49e818338a Changes from background composer bc-abfadb27-a265-41a7-b0db-829879f4701e 2025-07-09 23:15:25 +10:00
Cursor Agent
1caab2b9c4 Implement automatic reference image model switching on base model change
Co-authored-by: kent <kent@invoke.ai>
2025-07-09 23:15:25 +10:00
psychedelicious
50079ea349 fix(ui): big red cancel button has diff behaviour than staging discard 2025-07-09 23:15:25 +10:00
psychedelicious
fffa1b24c4 fix(ui): isStaging selector could return wrong query cache 2025-07-09 23:15:25 +10:00
psychedelicious
a6d6170387 fix(ui): discarding 1 item when 2 items left in staging area discards both 2025-07-09 23:15:25 +10:00
psychedelicious
e5fceb0448 fix(ui): whole app scrolls while selecting staging area image 2025-07-09 23:15:25 +10:00
psychedelicious
059baf5b29 chore(ui): lint 2025-07-09 23:15:25 +10:00
psychedelicious
1be8a9a310 fix(ui): add metadata i18nKey to handler; fixes metadata toasts 2025-07-09 23:15:25 +10:00
psychedelicious
7adc33e04d refactor(ui): metadata recall buttons & hotkeys (WIP) 2025-07-09 23:15:25 +10:00
psychedelicious
7f2dd22d47 refactor(ui): metadata recall buttons & hotkeys (WIP) 2025-07-09 23:15:25 +10:00
psychedelicious
bb50f4b8a2 fix(ui): prevent panels from growing on init
This works but I think a better solution is to use dockview's provided
serialization API to store and restore layouts.
2025-07-09 23:15:25 +10:00
psychedelicious
a48958e0d4 chore(ui): lint 2025-07-09 23:15:25 +10:00
psychedelicious
e3a1e9af53 feat(ui): staging area updates
- Smaller staged image previews.
- Move autoswitch buttons to staging area toolbar, remove from settings
popover and the little three-dots menu. Use persisted autoswitch
setting, which is renamed from `defaultAutoSwitch` to
`stagingAreaAutoSwitch`.
- Fix issue with misaligned border radii in staging area preview images.
Required small changes to DndImage and its usage elsewhere.
- Fix issue where staging area toolbar could show up without any
previews in the list.
- Migrate canvas settings slice to use zod schema and inferred types for
its state.
2025-07-09 23:15:25 +10:00
psychedelicious
c6fe11c42f fix(ui): disable gallery hotkeys when in staging area 2025-07-09 23:15:25 +10:00
psychedelicious
4eb1bd67df fix(ui): hide staging area when there are no items 2025-07-09 23:15:25 +10:00
psychedelicious
c376f914d2 chore: bump version v6.0.0 2025-07-09 23:15:25 +10:00
Kent Keirsey
b5d1c47ef7 final link fix 2025-07-09 10:17:38 +10:00
Kent Keirsey
004a52ca65 fix to direct links 2025-07-09 10:17:38 +10:00
Kent Keirsey
b1d5a51ddf add-quantized-kontext-dev 2025-07-09 10:17:38 +10:00
Kent Keirsey
2b2498eaa1 fix prettier quirk 2025-07-08 14:54:29 -04:00
Kent Keirsey
10dda4440e Fix label 2025-07-08 14:54:29 -04:00
Cursor Agent
98f78abefa Add default auto-switch mode setting for canvas sessions
Co-authored-by: kent <kent@invoke.ai>
2025-07-08 14:54:29 -04:00
Mary Hipp Rogers
cc93fa270f update whats new for v6 (#8234)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 18:24:33 +00:00
Mary Hipp Rogers
014b27680f fix flux kontext error (#8235)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 13:42:48 -04:00
Mary Hipp Rogers
c3d8f875de if on generate tab, recall dimensions instead of bbox (#8233)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 13:09:21 -04:00
Mary Hipp Rogers
79f9dc6e4a fix(ui): dont show option to add new layer from if on generate tab (#8231)
* dont show option to add new layer from if on generate tab

* only disable width/height recall is staging AND canvas tab

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 11:46:54 -04:00
psychedelicious
6e1c0c1105 chore: bump version to v6.0.0rc5 2025-07-08 11:26:47 -04:00
Mary Hipp Rogers
0362524040 remove hard-coded flux kontext dev guidance (#8230)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 10:26:20 -04:00
psychedelicious
dc6656459b docs(ui): updated comments for navigation api 2025-07-08 07:30:36 -04:00
psychedelicious
3ea1b97f6f fix(ui): protect against getting stuck on tab loading screen 2025-07-08 07:30:36 -04:00
psychedelicious
a7c7405ccc feat(ui): style model picker selected item 2025-07-08 07:28:07 -04:00
psychedelicious
c391f1117a fix(ui): traverse groups when finding selected model in picker 2025-07-08 07:28:07 -04:00
psychedelicious
b1e2cb8401 fix(ui): queue tab list of queue items
Reverted incomplete change to how queue items are listed. In the future
I think we should redo it to work like the gallery. For now, it is back
the way it was in v5.
2025-07-08 07:22:51 -04:00
Emmanuel Ferdman
db6af134b7 fix: resolve FastAPI deprecation warning for example fields
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-07-08 20:54:08 +10:00
Emmanuel Ferdman
7e6cffb00c fix: resolve FastAPI deprecation warning for example fields
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-07-08 20:54:08 +10:00
psychedelicious
5b187bcb00 fix(ui): pull bbox into ref image component 2025-07-08 14:54:43 +10:00
psychedelicious
0843d609a3 feat(ui): add list of warnings in tooltip on ref image 2025-07-08 14:54:43 +10:00
Kent Keirsey
95bd9cef18 Lint 2025-07-08 14:54:43 +10:00
Kent Keirsey
931d6521f6 Adds bbox to ref image button 2025-07-08 14:54:43 +10:00
psychedelicious
e37665ff59 tests(ui): add wiggle room to timeout tests 2025-07-08 12:55:33 +10:00
psychedelicious
56857fbbe6 tests(ui): add tests for panel storage 2025-07-08 12:55:33 +10:00
psychedelicious
43cfb8a574 tests(ui): get tests passing
Still need tests for panel storage.
2025-07-08 12:55:33 +10:00
psychedelicious
05b1682d15 fix(ui): handle collapsed panels when rehydrating their state 2025-07-08 12:55:33 +10:00
psychedelicious
69a08ee7f2 feat(ui): panel state persistence (WIP) 2025-07-08 12:55:33 +10:00
psychedelicious
18212c7d8a feat(ui): clean up navigation API surface and add comments 2025-07-08 12:55:33 +10:00
psychedelicious
7de26f8e69 feat(ui): clean up auto layout context for panels 2025-07-08 12:55:33 +10:00
Kent Keirsey
0652b12a6f Address comments 2025-07-08 12:31:11 +10:00
Kent Keirsey
43a361a00f prettier 2025-07-08 12:31:11 +10:00
Kent Keirsey
cf68ad9cbc update links to playlist instead of video 2025-07-08 12:31:11 +10:00
Kent Keirsey
ec02a39325 fixes 2025-07-08 12:31:11 +10:00
Kent Keirsey
e52d7a05c2 Update support links. 2025-07-08 12:31:11 +10:00
Cursor Agent
c9d4e2b761 Refactor support videos modal to simplify video and playlist handling
Co-authored-by: kent <kent@invoke.ai>
2025-07-08 12:31:11 +10:00
Kent Keirsey
ac26aa9508 fix 2025-07-08 12:31:11 +10:00
Cursor Agent
9ff6ada15b Add support for video playlists in support videos modal
Co-authored-by: kent <kent@invoke.ai>
2025-07-08 12:31:11 +10:00
psychedelicious
e81a115169 chore(ui): lint 2025-07-08 12:23:57 +10:00
Kent Keirsey
52827807de remove ref image from upscale 2025-07-08 12:23:57 +10:00
Kent Keirsey
b631de4cb5 consistency 2025-07-08 12:20:08 +10:00
Kent Keirsey
099ebdbc37 fix 2025-07-08 12:20:08 +10:00
psychedelicious
4de6549be9 refactor(ui): track discarded items instead of using delete method 2025-07-08 12:12:55 +10:00
psychedelicious
368be34949 chore(ui): lint 2025-07-08 12:12:55 +10:00
psychedelicious
5baa4bd916 refactor(ui): use cancelation for staging area (mostly) 2025-07-08 12:12:55 +10:00
psychedelicious
4229377532 fix(app): ensure cancel events are emitted for current item when bulk canceling
There was a bug where bulk cancel operations would cancel the current
queue item in the DB but not emit the status changed events correctly.
2025-07-08 12:12:55 +10:00
psychedelicious
2610772ffd feat(ui): tighten up launchpad content to fit better 2025-07-08 08:57:44 +10:00
psychedelicious
193de6a8f2 feat(ui): add launchpad container component 2025-07-08 08:57:44 +10:00
psychedelicious
7ea343c787 tidy(ui): remove "staging" from the new settings verbiage 2025-07-08 07:10:55 +10:00
Kent Keirsey
12179dabba fix prettier 2025-07-08 07:10:55 +10:00
Cursor Agent
ef135f9923 Add option to save all staging images to gallery in canvas mode
Co-authored-by: kent <kent@invoke.ai>
2025-07-08 07:10:55 +10:00
Mary Hipp
e6c67cc00f update toast for prompt expansion failed 2025-07-08 06:42:00 +10:00
psychedelicious
179b988148 fix(ui): prompt concat derived state recall 2025-07-08 06:37:43 +10:00
psychedelicious
d913a3c85b fix(ui): reset selected ref image when replacing all
Fixes an unhandled error in a selector that can throw.
2025-07-08 06:37:43 +10:00
psychedelicious
e79525c40c docs(ui): update comments 2025-07-08 06:11:32 +10:00
psychedelicious
f409f913ac fix(ui): navigation api usage 2025-07-08 06:11:32 +10:00
Mary Hipp
7a79f61d4c add claude nodes to blacklist for publishing 2025-07-08 05:50:40 +10:00
psychedelicious
ea182c234b chore: bump version to v6.0.0rc4 2025-07-07 22:15:28 +10:00
psychedelicious
f2eee4a82d chore(ui): lint 2025-07-07 22:05:49 +10:00
psychedelicious
e129525306 fix(app): handle None in queue count queries 2025-07-07 22:05:49 +10:00
psychedelicious
ecedfce758 feat(ui): support a min expanded size for collapsible panels 2025-07-07 22:05:49 +10:00
psychedelicious
702cb2cb1e fix(ui): flux kontext special handlign for ref image models 2025-07-07 22:05:49 +10:00
psychedelicious
2e8db3cce3 fix(ui): ensure noise is correctly sized 2025-07-07 22:05:49 +10:00
psychedelicious
7845623fa5 fix(ui): session context indexing bug 2025-07-07 22:05:49 +10:00
psychedelicious
e6a25ca7a2 feat(ui): render progress as indeterminate when percentage is 0
When percentage is zero, the progress bar looks the same as it does when
no generation is in progress. Render it as indeterminate (pulsing) when
percentage is zero to indicate that somethign is happenign.
2025-07-07 22:05:49 +10:00
psychedelicious
71e12bcebe fix(ui): when no negative prompt is provided, recall it as null 2025-07-07 22:05:49 +10:00
psychedelicious
863c7eb9e2 fix(ui): metadata display for primitive values 2025-07-07 22:05:49 +10:00
psychedelicious
9945c20d02 refactor(ui): simplifiy graph builders (WIP) 2025-07-07 22:05:49 +10:00
psychedelicious
e3c1334b1f refactor(ui): simplifiy graph builders (WIP) 2025-07-07 22:05:49 +10:00
psychedelicious
c143f63ef0 refactor(ui): simplifiy graph builders (WIP) 2025-07-07 22:05:49 +10:00
psychedelicious
067026a0d0 feat(ui): add autocomplete for Graph.addEdgeToMetadata 2025-07-07 22:05:49 +10:00
psychedelicious
66991334fc refactor(ui): simplify graph builder handling of VAE encode and seed 2025-07-07 22:05:49 +10:00
psychedelicious
b771c3b164 refactor(ui): update graphs to use the right w/h/aspect 2025-07-07 22:05:49 +10:00
psychedelicious
4925694dc1 feat(ui): generate tab has separate w/h/aspect 2025-07-07 22:05:49 +10:00
psychedelicious
0a737ced44 feat(ui): add dimensions to params slice 2025-07-07 22:05:49 +10:00
psychedelicious
8d83caaae0 feat(ui): extract aspect ratios from canvas reducers 2025-07-07 22:05:49 +10:00
psychedelicious
16c8017f1a feat(ui): more resilient gallery scrollIntoView 2025-07-07 22:05:49 +10:00
psychedelicious
61a35f1396 fix(ui): skip optimistic updates for gallery when using search term 2025-07-07 22:05:49 +10:00
psychedelicious
6bd004d868 fix(ui): clear ref images when recalling all
Closes #8202
2025-07-07 22:05:49 +10:00
psychedelicious
b6a6d406c7 chore(ui): typegen 2025-07-07 10:25:24 +10:00
psychedelicious
8e287c32ee chore(ui): lint 2025-07-07 10:25:24 +10:00
psychedelicious
2d8b5e26c2 build(ui): bump vite to latest 2025-07-07 10:25:24 +10:00
psychedelicious
50914b74ee chore(build): update pnpm to v10 2025-07-07 10:25:24 +10:00
psychedelicious
0fc1c33536 chore(ui): knip 2025-07-07 10:25:24 +10:00
psychedelicious
3b08c35f72 chore(ui): update knip config 2025-07-07 10:25:24 +10:00
psychedelicious
607b2561fd chore(ui): bump knip to latest 2025-07-07 10:25:24 +10:00
psychedelicious
d68f922efb fix(ui): restore upscale-tab-specific settings components 2025-07-07 10:25:24 +10:00
psychedelicious
2bbd74d418 feat(ui): restore canvas busy spinner 2025-07-07 10:25:24 +10:00
psychedelicious
3a5392a9ee chore: bump version to v6.0.0rc3 2025-07-04 20:46:08 +10:00
psychedelicious
6f80efe71d fix(ui): bump expandprompt timeout to 15s 2025-07-04 20:46:08 +10:00
psychedelicious
7fac833813 fix(ui): ref image model types again 2025-07-04 20:35:29 +10:00
psychedelicious
b67eb4134d fix(ui): select next image when deleting 2025-07-04 20:35:29 +10:00
psychedelicious
522eeda2e2 fix(ui): ref image model types 2025-07-04 20:35:29 +10:00
psychedelicious
76233241f0 fix(ui): include ref image metadata for flux kontext 2025-07-04 20:35:29 +10:00
psychedelicious
54be9989c5 feat(ui): add 'replace' and 'merge' strategies for upsertMetadata 2025-07-04 20:35:29 +10:00
psychedelicious
0d3af08d27 fix(ui): prompt parsing in useImageActions 2025-07-04 20:35:29 +10:00
psychedelicious
767ac91f2c fix(nodes): revert unnecessary version bump 2025-07-04 20:35:29 +10:00
psychedelicious
68571ece8f tidy(app): remove unused methods 2025-07-04 20:35:29 +10:00
psychedelicious
01100a2b9a fix(ui): check for ref image config compatibility for flux kontext dev 2025-07-04 20:35:29 +10:00
psychedelicious
ce2e6d8ab6 fix(ui): kontext gen mode error tkey 2025-07-04 20:35:29 +10:00
psychedelicious
4887424ca3 chore: ruff 2025-07-04 20:35:29 +10:00
Kent Keirsey
28f6a20e71 format import block 2025-07-04 20:35:29 +10:00
Kent Keirsey
c4142e75b2 fix import 2025-07-04 20:35:29 +10:00
Kent Keirsey
fefe563127 fix resizing and versioning 2025-07-04 20:35:29 +10:00
Mary Hipp
1c72f1ff9f include flux kontext non-api models in ref image dropdown options 2025-07-04 20:35:29 +10:00
Mary Hipp
605cc7369d update flux kontext implementation to include flux kontext dev non-api models 2025-07-04 20:35:29 +10:00
Kent Keirsey
e7ce08cffa ruff format 2025-07-04 19:24:44 +10:00
Kent Keirsey
983cb5ebd2 ruff ruff 2025-07-04 19:24:44 +10:00
Kent Keirsey
52dbdb7118 ruff 2025-07-04 19:24:44 +10:00
Kent Keirsey
71e6f00e10 test fixes
fix

test

fix 2

fix 3

fix 4

yet another

attempt new fix

pray

more pray

lol
2025-07-04 19:24:44 +10:00
psychedelicious
e73150c3e6 feat(ui): improved automatic tab/panel switching on user actions 2025-07-04 19:18:03 +10:00
psychedelicious
f2426c3ab2 fix(ui): type for dnd action 2025-07-04 19:18:03 +10:00
psychedelicious
9d9c4c0f1a tidy(ui): remove unused old metadata impl 2025-07-04 17:53:47 +10:00
psychedelicious
acb930f6b9 fix(ui): flux redux saves metadata 2025-07-04 17:53:47 +10:00
psychedelicious
585b54dc7d feat(ui): ref image recall w/ old canvas metadata backup 2025-07-04 17:53:47 +10:00
psychedelicious
f65affc0ec fix(ui): do not attempt to recall ref images from canvas metadata 2025-07-04 17:53:47 +10:00
psychedelicious
22d574c92a feat(ui): canvas metadata recall 2025-07-04 17:53:47 +10:00
psychedelicious
f23be119fc refactor(ui): migrating to new metadata handlers 2025-07-04 17:53:47 +10:00
psychedelicious
2d06949e80 feat(ui): display cached metadata if it exists instead of always waiting for debounce 2025-07-04 17:53:47 +10:00
psychedelicious
67804313e1 fix(ui): add ref images to metadata 2025-07-04 17:53:47 +10:00
psychedelicious
dc23be117a refactor(ui): simplified metadata parsing (WIP) 2025-07-04 17:53:47 +10:00
psychedelicious
350de058fc refactor(ui): simplified metadata parsing (WIP) 2025-07-04 17:53:47 +10:00
psychedelicious
fd5cd707a3 refactor(ui): simplified metadata parsing (WIP) 2025-07-04 17:53:47 +10:00
psychedelicious
98ecefdce0 refactor(ui): simplified metadata parsing (WIP) 2025-07-04 17:53:47 +10:00
psychedelicious
42688a0993 refactor(ui): metadata parsing 2025-07-04 17:53:47 +10:00
psychedelicious
d94aa4abf7 feat(ui): enforce loader when switching tabs 2025-07-04 16:49:57 +10:00
psychedelicious
69a56aafed feat(ui): do not require root ref to focus on prompt 2025-07-04 16:49:57 +10:00
psychedelicious
56873f6936 feat(ui): queue and models tab are wrapped in dockview panels 2025-07-04 16:49:57 +10:00
psychedelicious
6bc6a680cf tests(ui): NavigationApi 2025-07-04 16:49:57 +10:00
psychedelicious
9a49682f60 feat(ui): utils to get tab/panel keys to prevent typos 2025-07-04 16:49:57 +10:00
psychedelicious
ff84b0a495 refactor(ui): navigation api 2025-07-04 16:49:57 +10:00
psychedelicious
bcced8a5e8 refactor(ui): navigation api 2025-07-04 16:49:57 +10:00
psychedelicious
4a18e9eaea refactor(ui): panel api (WIP) 2025-07-04 16:49:57 +10:00
psychedelicious
dde5bf61be feat(ui): use exact brand colors in loader 2025-07-04 16:49:57 +10:00
psychedelicious
987e401709 perf(ui): lora components 2025-07-04 14:55:52 +10:00
psychedelicious
5c5ac570e3 fix(ui): hardcode literals for run graph errors
When we build, the class names are minified. This hardcodes the values
to literals.
2025-07-04 14:52:08 +10:00
psychedelicious
309903fe0f feat(ui): refetch gallery image names on reconnect
Maybe fixes JP's issue (again)
2025-07-04 14:49:32 +10:00
psychedelicious
f16ea43e9a feat(ui): enable RTK Query's refetchOnReconnect 2025-07-04 14:49:32 +10:00
Jeremy Gooch
d794aedb43 fix(ui): sets cfg_rescael_multiplier to 0 if there is no default. Also fixes issue with truthiness check causing 0 value to be missed. See https://github.com/invoke-ai/InvokeAI/issues/7584 2025-07-04 06:20:14 +10:00
283 changed files with 15162 additions and 13319 deletions

View File

@@ -3,15 +3,15 @@ description: Installs frontend dependencies with pnpm, with caching
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
- name: setup node 18 - name: setup node 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20'
- name: setup pnpm - name: setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 8.15.6 version: 10
run_install: false run_install: false
- name: get pnpm store directory - name: get pnpm store directory

View File

@@ -72,7 +72,7 @@ async def upload_image(
resize_to: Optional[str] = Body( resize_to: Optional[str] = Body(
default=None, default=None,
description=f"Dimensions to resize the image to, must be stringified tuple of 2 integers. Max total pixel count: {ResizeToDimensions.MAX_SIZE}", description=f"Dimensions to resize the image to, must be stringified tuple of 2 integers. Max total pixel count: {ResizeToDimensions.MAX_SIZE}",
example='"[1024,1024]"', examples=['"[1024,1024]"'],
), ),
metadata: Optional[str] = Body( metadata: Optional[str] = Body(
default=None, default=None,

View File

@@ -292,7 +292,7 @@ async def get_hugging_face_models(
) )
async def update_model_record( async def update_model_record(
key: Annotated[str, Path(description="Unique key of model")], key: Annotated[str, Path(description="Unique key of model")],
changes: Annotated[ModelRecordChanges, Body(description="Model config", example=example_model_input)], changes: Annotated[ModelRecordChanges, Body(description="Model config", examples=[example_model_input])],
) -> AnyModelConfig: ) -> AnyModelConfig:
"""Update a model's config.""" """Update a model's config."""
logger = ApiDependencies.invoker.services.logger logger = ApiDependencies.invoker.services.logger
@@ -450,7 +450,7 @@ async def install_model(
access_token: Optional[str] = Query(description="access token for the remote resource", default=None), access_token: Optional[str] = Query(description="access token for the remote resource", default=None),
config: ModelRecordChanges = Body( config: ModelRecordChanges = Body(
description="Object containing fields that override auto-probed values in the model config record, such as name, description and prediction_type ", description="Object containing fields that override auto-probed values in the model config record, such as name, description and prediction_type ",
example={"name": "string", "description": "string"}, examples=[{"name": "string", "description": "string"}],
), ),
) -> ModelInstallJob: ) -> ModelInstallJob:
"""Install a model using a string identifier. """Install a model using a string identifier.

View File

@@ -391,28 +391,29 @@ class FluxDenoiseInvocation(BaseInvocation):
raise ValueError("A VAE (e.g., controlnet_vae) must be provided to use Kontext conditioning.") raise ValueError("A VAE (e.g., controlnet_vae) must be provided to use Kontext conditioning.")
kontext_extension = KontextExtension( kontext_extension = KontextExtension(
kontext_field=self.kontext_conditioning,
context=context, context=context,
kontext_conditioning=self.kontext_conditioning,
vae_field=self.controlnet_vae, vae_field=self.controlnet_vae,
device=TorchDevice.choose_torch_device(), device=TorchDevice.choose_torch_device(),
dtype=inference_dtype, dtype=inference_dtype,
) )
final_img, final_img_ids = x, img_ids # Prepare Kontext conditioning if provided
original_seq_len = x.shape[1] img_cond_seq = None
img_cond_seq_ids = None
if kontext_extension is not None: if kontext_extension is not None:
final_img, final_img_ids = kontext_extension.apply(final_img, final_img_ids) # Ensure batch sizes match
kontext_extension.ensure_batch_size(x.shape[0])
img_cond_seq, img_cond_seq_ids = kontext_extension.kontext_latents, kontext_extension.kontext_ids
x = denoise( x = denoise(
model=transformer, model=transformer,
img=final_img, img=x,
img_ids=final_img_ids, img_ids=img_ids,
pos_regional_prompting_extension=pos_regional_prompting_extension, pos_regional_prompting_extension=pos_regional_prompting_extension,
neg_regional_prompting_extension=neg_regional_prompting_extension, neg_regional_prompting_extension=neg_regional_prompting_extension,
timesteps=timesteps, timesteps=timesteps,
step_callback=self._build_step_callback( step_callback=self._build_step_callback(context),
context, original_seq_len if kontext_extension is not None else None
),
guidance=self.guidance, guidance=self.guidance,
cfg_scale=cfg_scale, cfg_scale=cfg_scale,
inpaint_extension=inpaint_extension, inpaint_extension=inpaint_extension,
@@ -420,11 +421,10 @@ class FluxDenoiseInvocation(BaseInvocation):
pos_ip_adapter_extensions=pos_ip_adapter_extensions, pos_ip_adapter_extensions=pos_ip_adapter_extensions,
neg_ip_adapter_extensions=neg_ip_adapter_extensions, neg_ip_adapter_extensions=neg_ip_adapter_extensions,
img_cond=img_cond, img_cond=img_cond,
img_cond_seq=img_cond_seq,
img_cond_seq_ids=img_cond_seq_ids,
) )
if kontext_extension is not None:
x = x[:, :original_seq_len, :] # Keep only the first original_seq_len tokens
x = unpack(x.float(), self.height, self.width) x = unpack(x.float(), self.height, self.width)
return x return x
@@ -895,14 +895,11 @@ class FluxDenoiseInvocation(BaseInvocation):
yield (lora_info.model, lora.weight) yield (lora_info.model, lora.weight)
del lora_info del lora_info
def _build_step_callback( def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
self, context: InvocationContext, original_seq_len: Optional[int] = None
) -> Callable[[PipelineIntermediateState], None]:
def step_callback(state: PipelineIntermediateState) -> None: def step_callback(state: PipelineIntermediateState) -> None:
# Extract only main image tokens if Kontext conditioning was applied # The denoise function now handles Kontext conditioning correctly,
# so we don't need to slice the latents here
latents = state.latents.float() latents = state.latents.float()
if original_seq_len is not None:
latents = latents[:, :original_seq_len, :]
state.latents = unpack(latents, self.height, self.width).squeeze() state.latents = unpack(latents, self.height, self.width).squeeze()
context.util.flux_step_callback(state) context.util.flux_step_callback(state)

View File

@@ -404,6 +404,8 @@ class SqliteSessionQueue(SessionQueueBase):
AND status != 'canceled' AND status != 'canceled'
AND status != 'completed' AND status != 'completed'
AND status != 'failed' AND status != 'failed'
-- We will cancel the current item separately below - skip it here
AND status != 'in_progress'
""" """
params = [queue_id] + batch_ids params = [queue_id] + batch_ids
cursor.execute( cursor.execute(
@@ -442,6 +444,8 @@ class SqliteSessionQueue(SessionQueueBase):
AND status != 'canceled' AND status != 'canceled'
AND status != 'completed' AND status != 'completed'
AND status != 'failed' AND status != 'failed'
-- We will cancel the current item separately below - skip it here
AND status != 'in_progress'
""" """
params = (queue_id, destination) params = (queue_id, destination)
cursor.execute( cursor.execute(
@@ -544,6 +548,8 @@ class SqliteSessionQueue(SessionQueueBase):
AND status != 'canceled' AND status != 'canceled'
AND status != 'completed' AND status != 'completed'
AND status != 'failed' AND status != 'failed'
-- We will cancel the current item separately below - skip it here
AND status != 'in_progress'
""" """
params = [queue_id] params = [queue_id]
cursor.execute( cursor.execute(
@@ -564,12 +570,9 @@ class SqliteSessionQueue(SessionQueueBase):
tuple(params), tuple(params),
) )
self._conn.commit() self._conn.commit()
if current_queue_item is not None and current_queue_item.queue_id == queue_id: if current_queue_item is not None and current_queue_item.queue_id == queue_id:
batch_status = self.get_batch_status(queue_id=queue_id, batch_id=current_queue_item.batch_id) self._set_queue_item_status(current_queue_item.item_id, "canceled")
queue_status = self.get_queue_status(queue_id=queue_id)
self.__invoker.services.events.emit_queue_item_status_changed(
current_queue_item, batch_status, queue_status
)
except Exception: except Exception:
self._conn.rollback() self._conn.rollback()
raise raise
@@ -740,7 +743,7 @@ class SqliteSessionQueue(SessionQueueBase):
counts_result = cast(list[sqlite3.Row], cursor.fetchall()) counts_result = cast(list[sqlite3.Row], cursor.fetchall())
current_item = self.get_current(queue_id=queue_id) current_item = self.get_current(queue_id=queue_id)
total = sum(row[1] for row in counts_result) total = sum(row[1] or 0 for row in counts_result)
counts: dict[str, int] = {row[0]: row[1] for row in counts_result} counts: dict[str, int] = {row[0]: row[1] for row in counts_result}
return SessionQueueStatus( return SessionQueueStatus(
queue_id=queue_id, queue_id=queue_id,
@@ -769,7 +772,7 @@ class SqliteSessionQueue(SessionQueueBase):
(queue_id, batch_id), (queue_id, batch_id),
) )
result = cast(list[sqlite3.Row], cursor.fetchall()) result = cast(list[sqlite3.Row], cursor.fetchall())
total = sum(row[1] for row in result) total = sum(row[1] or 0 for row in result)
counts: dict[str, int] = {row[0]: 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 origin = result[0]["origin"] if result else None
destination = result[0]["destination"] if result else None destination = result[0]["destination"] if result else None
@@ -801,7 +804,7 @@ class SqliteSessionQueue(SessionQueueBase):
) )
counts_result = cast(list[sqlite3.Row], cursor.fetchall()) counts_result = cast(list[sqlite3.Row], cursor.fetchall())
total = sum(row[1] for row in counts_result) total = sum(row[1] or 0 for row in counts_result)
counts: dict[str, int] = {row[0]: row[1] for row in counts_result} counts: dict[str, int] = {row[0]: row[1] for row in counts_result}
return SessionQueueCountsByDestination( return SessionQueueCountsByDestination(

View File

@@ -30,8 +30,11 @@ def denoise(
controlnet_extensions: list[XLabsControlNetExtension | InstantXControlNetExtension], controlnet_extensions: list[XLabsControlNetExtension | InstantXControlNetExtension],
pos_ip_adapter_extensions: list[XLabsIPAdapterExtension], pos_ip_adapter_extensions: list[XLabsIPAdapterExtension],
neg_ip_adapter_extensions: list[XLabsIPAdapterExtension], neg_ip_adapter_extensions: list[XLabsIPAdapterExtension],
# extra img tokens # extra img tokens (channel-wise)
img_cond: torch.Tensor | None, img_cond: torch.Tensor | None,
# extra img tokens (sequence-wise) - for Kontext conditioning
img_cond_seq: torch.Tensor | None = None,
img_cond_seq_ids: torch.Tensor | None = None,
): ):
# step 0 is the initial state # step 0 is the initial state
total_steps = len(timesteps) - 1 total_steps = len(timesteps) - 1
@@ -46,6 +49,10 @@ def denoise(
) )
# guidance_vec is ignored for schnell. # guidance_vec is ignored for schnell.
guidance_vec = torch.full((img.shape[0],), guidance, device=img.device, dtype=img.dtype) guidance_vec = torch.full((img.shape[0],), guidance, device=img.device, dtype=img.dtype)
# Store original sequence length for slicing predictions
original_seq_len = img.shape[1]
for step_index, (t_curr, t_prev) in tqdm(list(enumerate(zip(timesteps[:-1], timesteps[1:], strict=True)))): for step_index, (t_curr, t_prev) in tqdm(list(enumerate(zip(timesteps[:-1], timesteps[1:], strict=True)))):
t_vec = torch.full((img.shape[0],), t_curr, dtype=img.dtype, device=img.device) t_vec = torch.full((img.shape[0],), t_curr, dtype=img.dtype, device=img.device)
@@ -71,10 +78,26 @@ def denoise(
# controlnet_residuals datastructure is efficient in that it likely contains multiple references to the same # controlnet_residuals datastructure is efficient in that it likely contains multiple references to the same
# tensors. Calculating the sum materializes each tensor into its own instance. # tensors. Calculating the sum materializes each tensor into its own instance.
merged_controlnet_residuals = sum_controlnet_flux_outputs(controlnet_residuals) merged_controlnet_residuals = sum_controlnet_flux_outputs(controlnet_residuals)
pred_img = torch.cat((img, img_cond), dim=-1) if img_cond is not None else img
# Prepare input for model - concatenate fresh each step
img_input = img
img_input_ids = img_ids
# Add channel-wise conditioning (for ControlNet, FLUX Fill, etc.)
if img_cond is not None:
img_input = torch.cat((img_input, img_cond), dim=-1)
# Add sequence-wise conditioning (for Kontext)
if img_cond_seq is not None:
assert img_cond_seq_ids is not None, (
"You need to provide either both or neither of the sequence conditioning"
)
img_input = torch.cat((img_input, img_cond_seq), dim=1)
img_input_ids = torch.cat((img_input_ids, img_cond_seq_ids), dim=1)
pred = model( pred = model(
img=pred_img, img=img_input,
img_ids=img_ids, img_ids=img_input_ids,
txt=pos_regional_prompting_extension.regional_text_conditioning.t5_embeddings, txt=pos_regional_prompting_extension.regional_text_conditioning.t5_embeddings,
txt_ids=pos_regional_prompting_extension.regional_text_conditioning.t5_txt_ids, txt_ids=pos_regional_prompting_extension.regional_text_conditioning.t5_txt_ids,
y=pos_regional_prompting_extension.regional_text_conditioning.clip_embeddings, y=pos_regional_prompting_extension.regional_text_conditioning.clip_embeddings,
@@ -88,6 +111,10 @@ def denoise(
regional_prompting_extension=pos_regional_prompting_extension, regional_prompting_extension=pos_regional_prompting_extension,
) )
# Slice prediction to only include the main image tokens
if img_input_ids is not None:
pred = pred[:, :original_seq_len]
step_cfg_scale = cfg_scale[step_index] step_cfg_scale = cfg_scale[step_index]
# If step_cfg_scale, is 1.0, then we don't need to run the negative prediction. # If step_cfg_scale, is 1.0, then we don't need to run the negative prediction.

View File

@@ -1,13 +1,15 @@
import einops import einops
import numpy as np
import torch import torch
from einops import repeat from einops import repeat
from PIL import Image
from invokeai.app.invocations.fields import FluxKontextConditioningField from invokeai.app.invocations.fields import FluxKontextConditioningField
from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation
from invokeai.app.invocations.model import VAEField from invokeai.app.invocations.model import VAEField
from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.backend.flux.sampling_utils import pack from invokeai.backend.flux.sampling_utils import pack
from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor from invokeai.backend.flux.util import PREFERED_KONTEXT_RESOLUTIONS
def generate_img_ids_with_offset( def generate_img_ids_with_offset(
@@ -71,7 +73,7 @@ class KontextExtension:
def __init__( def __init__(
self, self,
kontext_field: FluxKontextConditioningField, kontext_conditioning: FluxKontextConditioningField,
context: InvocationContext, context: InvocationContext,
vae_field: VAEField, vae_field: VAEField,
device: torch.device, device: torch.device,
@@ -85,30 +87,49 @@ class KontextExtension:
self._device = device self._device = device
self._dtype = dtype self._dtype = dtype
self._vae_field = vae_field self._vae_field = vae_field
self.kontext_field = kontext_field self.kontext_conditioning = kontext_conditioning
# Pre-process and cache the kontext latents and ids upon initialization. # Pre-process and cache the kontext latents and ids upon initialization.
self.kontext_latents, self.kontext_ids = self._prepare_kontext() self.kontext_latents, self.kontext_ids = self._prepare_kontext()
def _prepare_kontext(self) -> tuple[torch.Tensor, torch.Tensor]: def _prepare_kontext(self) -> tuple[torch.Tensor, torch.Tensor]:
"""Encodes the reference image and prepares its latents and IDs.""" """Encodes the reference image and prepares its latents and IDs."""
image = self._context.images.get_pil(self.kontext_field.image.image_name) image = self._context.images.get_pil(self.kontext_conditioning.image.image_name)
# Reuse VAE encoding logic from FluxVaeEncodeInvocation # Calculate aspect ratio of input image
vae_info = self._context.models.load(self._vae_field.vae) width, height = image.size
image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB")) aspect_ratio = width / height
if image_tensor.dim() == 3:
image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w") # Find the closest preferred resolution by aspect ratio
_, target_width, target_height = min(
((abs(aspect_ratio - w / h), w, h) for w, h in PREFERED_KONTEXT_RESOLUTIONS), key=lambda x: x[0]
)
# Apply BFL's scaling formula
# This ensures compatibility with the model's training
scaled_width = 2 * int(target_width / 16)
scaled_height = 2 * int(target_height / 16)
# Resize to the exact resolution used during training
image = image.convert("RGB")
final_width = 8 * scaled_width
final_height = 8 * scaled_height
image = image.resize((final_width, final_height), Image.Resampling.LANCZOS)
# Convert to tensor with same normalization as BFL
image_np = np.array(image)
image_tensor = torch.from_numpy(image_np).float() / 127.5 - 1.0
image_tensor = einops.rearrange(image_tensor, "h w c -> 1 c h w")
image_tensor = image_tensor.to(self._device) image_tensor = image_tensor.to(self._device)
# Continue with VAE encoding
vae_info = self._context.models.load(self._vae_field.vae)
kontext_latents_unpacked = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=image_tensor) kontext_latents_unpacked = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
# Extract tensor dimensions with descriptive names # Extract tensor dimensions
# Latent tensor shape: [batch_size, channels, latent_height, latent_width]
batch_size, _, latent_height, latent_width = kontext_latents_unpacked.shape batch_size, _, latent_height, latent_width = kontext_latents_unpacked.shape
# Pack the latents and generate IDs. The idx_offset distinguishes these # Pack the latents and generate IDs
# tokens from the main image's tokens, which have an index of 0.
kontext_latents_packed = pack(kontext_latents_unpacked).to(self._device, self._dtype) kontext_latents_packed = pack(kontext_latents_unpacked).to(self._device, self._dtype)
kontext_ids = generate_img_ids_with_offset( kontext_ids = generate_img_ids_with_offset(
latent_height=latent_height, latent_height=latent_height,
@@ -116,24 +137,13 @@ class KontextExtension:
batch_size=batch_size, batch_size=batch_size,
device=self._device, device=self._device,
dtype=self._dtype, dtype=self._dtype,
idx_offset=1, # Distinguishes reference tokens from main image tokens idx_offset=1,
) )
return kontext_latents_packed, kontext_ids return kontext_latents_packed, kontext_ids
def apply( def ensure_batch_size(self, target_batch_size: int) -> None:
self, """Ensures the kontext latents and IDs match the target batch size by repeating if necessary."""
img: torch.Tensor, if self.kontext_latents.shape[0] != target_batch_size:
img_ids: torch.Tensor, self.kontext_latents = self.kontext_latents.repeat(target_batch_size, 1, 1)
) -> tuple[torch.Tensor, torch.Tensor]: self.kontext_ids = self.kontext_ids.repeat(target_batch_size, 1, 1)
"""Concatenates the pre-processed kontext data to the main image sequence."""
# Ensure batch sizes match, repeating kontext data if necessary for batch operations.
if img.shape[0] != self.kontext_latents.shape[0]:
self.kontext_latents = self.kontext_latents.repeat(img.shape[0], 1, 1)
self.kontext_ids = self.kontext_ids.repeat(img.shape[0], 1, 1)
# Concatenate along the sequence dimension (dim=1)
combined_img = torch.cat([img, self.kontext_latents], dim=1)
combined_img_ids = torch.cat([img_ids, self.kontext_ids], dim=1)
return combined_img, combined_img_ids

View File

@@ -174,11 +174,13 @@ def generate_img_ids(h: int, w: int, batch_size: int, device: torch.device, dtyp
dtype = torch.float16 dtype = torch.float16
img_ids = torch.zeros(h // 2, w // 2, 3, device=device, dtype=dtype) img_ids = torch.zeros(h // 2, w // 2, 3, device=device, dtype=dtype)
# Set batch offset to 0 for main image tokens
img_ids[..., 0] = 0
img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // 2, device=device, dtype=dtype)[:, None] img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // 2, device=device, dtype=dtype)[:, None]
img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // 2, device=device, dtype=dtype)[None, :] img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // 2, device=device, dtype=dtype)[None, :]
img_ids = repeat(img_ids, "h w c -> b (h w) c", b=batch_size) img_ids = repeat(img_ids, "h w c -> b (h w) c", b=batch_size)
if device.type == "mps": if device.type == "mps":
img_ids.to(orig_dtype) img_ids = img_ids.to(orig_dtype)
return img_ids return img_ids

View File

@@ -18,6 +18,29 @@ class ModelSpec:
repo_ae: str | None repo_ae: str | None
# Preferred resolutions for Kontext models to avoid tiling artifacts
# These are the specific resolutions the model was trained on
PREFERED_KONTEXT_RESOLUTIONS = [
(672, 1568),
(688, 1504),
(720, 1456),
(752, 1392),
(800, 1328),
(832, 1248),
(880, 1184),
(944, 1104),
(1024, 1024),
(1104, 944),
(1184, 880),
(1248, 832),
(1328, 800),
(1392, 752),
(1456, 720),
(1504, 688),
(1568, 672),
]
max_seq_lengths: Dict[str, Literal[256, 512]] = { max_seq_lengths: Dict[str, Literal[256, 512]] = {
"flux-dev": 512, "flux-dev": 512,
"flux-dev-fill": 512, "flux-dev-fill": 512,

View File

@@ -143,11 +143,19 @@ flux_dev = StarterModel(
flux_kontext = StarterModel( flux_kontext = StarterModel(
name="FLUX.1 Kontext dev", name="FLUX.1 Kontext dev",
base=BaseModelType.Flux, base=BaseModelType.Flux,
source="black-forest-labs/FLUX.1-Kontext-dev::flux1-kontext-dev.safetensors", source="https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev/resolve/main/flux1-kontext-dev.safetensors",
description="FLUX.1 Kontext dev transformer in bfloat16. Total size with dependencies: ~33GB", description="FLUX.1 Kontext dev transformer in bfloat16. Total size with dependencies: ~33GB",
type=ModelType.Main, type=ModelType.Main,
dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], dependencies=[t5_base_encoder, flux_vae, clip_l_encoder],
) )
flux_kontext_quantized = StarterModel(
name="FLUX.1 Kontext dev (Quantized)",
base=BaseModelType.Flux,
source="https://huggingface.co/unsloth/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_M.gguf",
description="FLUX.1 Kontext dev quantized (q4_k_m). Total size with dependencies: ~14GB",
type=ModelType.Main,
dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder],
)
sd35_medium = StarterModel( sd35_medium = StarterModel(
name="SD3.5 Medium", name="SD3.5 Medium",
base=BaseModelType.StableDiffusion3, base=BaseModelType.StableDiffusion3,
@@ -664,7 +672,7 @@ flux_fill = StarterModel(
# List of starter models, displayed on the frontend. # List of starter models, displayed on the frontend.
# The order/sort of this list is not changed by the frontend - set it how you want it here. # The order/sort of this list is not changed by the frontend - set it how you want it here.
STARTER_MODELS: list[StarterModel] = [ STARTER_MODELS: list[StarterModel] = [
flux_kontext, flux_kontext_quantized,
flux_schnell_quantized, flux_schnell_quantized,
flux_dev_quantized, flux_dev_quantized,
flux_schnell, flux_schnell,
@@ -785,7 +793,7 @@ flux_bundle: list[StarterModel] = [
flux_depth_control_lora, flux_depth_control_lora,
flux_redux, flux_redux,
flux_fill, flux_fill,
flux_kontext, flux_kontext_quantized,
] ]
STARTER_BUNDLES: dict[str, StarterModelBundle] = { STARTER_BUNDLES: dict[str, StarterModelBundle] = {

View File

@@ -17,6 +17,15 @@ module.exports = {
'no-promise-executor-return': 'error', 'no-promise-executor-return': 'error',
// https://eslint.org/docs/latest/rules/require-await // https://eslint.org/docs/latest/rules/require-await
'require-await': 'error', 'require-await': 'error',
// Restrict setActiveTab calls to only use-navigation-api.tsx
'no-restricted-syntax': [
'error',
{
selector: 'CallExpression[callee.name="setActiveTab"]',
message:
'setActiveTab() can only be called from use-navigation-api.tsx. Use navigationApi.switchToTab() instead.',
},
],
// TODO: ENABLE THIS RULE BEFORE v6.0.0 // TODO: ENABLE THIS RULE BEFORE v6.0.0
'react/display-name': 'off', 'react/display-name': 'off',
'no-restricted-properties': [ 'no-restricted-properties': [
@@ -56,6 +65,15 @@ module.exports = {
], ],
}, },
overrides: [ overrides: [
/**
* Allow setActiveTab calls only in use-navigation-api.tsx
*/
{
files: ['**/use-navigation-api.tsx'],
rules: {
'no-restricted-syntax': 'off',
},
},
/** /**
* Overrides for stories * Overrides for stories
*/ */

View File

@@ -3,8 +3,6 @@ import type { KnipConfig } from 'knip';
const config: KnipConfig = { const config: KnipConfig = {
project: ['src/**/*.{ts,tsx}!'], project: ['src/**/*.{ts,tsx}!'],
ignore: [ ignore: [
// TODO(psyche): temporarily ignored all files for test build purposes
'src/**',
// This file is only used during debugging // This file is only used during debugging
'src/app/store/middleware/debugLoggerMiddleware.ts', 'src/app/store/middleware/debugLoggerMiddleware.ts',
// Autogenerated types - shouldn't ever touch these // Autogenerated types - shouldn't ever touch these
@@ -14,10 +12,8 @@ const config: KnipConfig = {
'src/features/parameters/types/parameterSchemas.ts', 'src/features/parameters/types/parameterSchemas.ts',
// TODO(psyche): maybe we can clean up these utils after canvas v2 release // TODO(psyche): maybe we can clean up these utils after canvas v2 release
'src/features/controlLayers/konva/util.ts', 'src/features/controlLayers/konva/util.ts',
// TODO(psyche): restore HRF functionality? // Will be using this
'src/features/hrf/**', 'src/common/hooks/useAsyncState.ts',
// This feature is (temprarily?) disabled
'src/features/controlLayers/components/InpaintMask/InpaintMaskAddButtons.tsx',
], ],
ignoreBinaries: ['only-allow'], ignoreBinaries: ['only-allow'],
paths: { paths: {

View File

@@ -38,19 +38,6 @@
"test:ui": "vitest --coverage --ui", "test:ui": "vitest --coverage --ui",
"test:no-watch": "vitest --no-watch" "test:no-watch": "vitest --no-watch"
}, },
"madge": {
"excludeRegExp": [
"^index.ts$"
],
"detectiveOptions": {
"ts": {
"skipTypeImports": true
},
"tsx": {
"skipTypeImports": true
}
}
},
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.1", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.1",
@@ -147,7 +134,7 @@
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-plugin-i18next": "^6.1.1", "eslint-plugin-i18next": "^6.1.1",
"eslint-plugin-path": "^1.3.0", "eslint-plugin-path": "^1.3.0",
"knip": "^5.50.5", "knip": "^5.61.3",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"openapi-typescript": "^7.6.1", "openapi-typescript": "^7.6.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
@@ -156,7 +143,7 @@
"tsafe": "^1.8.5", "tsafe": "^1.8.5",
"type-fest": "^4.40.0", "type-fest": "^4.40.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.3", "vite": "^7.0.2",
"vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-css-injected-by-js": "^3.5.2",
"vite-plugin-dts": "^4.5.3", "vite-plugin-dts": "^4.5.3",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
@@ -164,7 +151,7 @@
"vitest": "^3.1.2" "vitest": "^3.1.2"
}, },
"engines": { "engines": {
"pnpm": "8" "pnpm": "10"
}, },
"packageManager": "pnpm@8.15.9+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81" "packageManager": "pnpm@10.12.4"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@swc/core'
- esbuild

View File

@@ -762,7 +762,7 @@
"vae": "VAE", "vae": "VAE",
"width": "Width", "width": "Width",
"workflow": "Workflow", "workflow": "Workflow",
"canvasV2Metadata": "Canvas" "canvasV2Metadata": "Canvas Layers"
}, },
"modelManager": { "modelManager": {
"active": "active", "active": "active",
@@ -1399,7 +1399,7 @@
"fluxFillIncompatibleWithT2IAndI2I": "FLUX Fill is not compatible with Text to Image or Image to Image. Use other FLUX models for these tasks.", "fluxFillIncompatibleWithT2IAndI2I": "FLUX Fill is not compatible with Text to Image or Image to Image. Use other FLUX models for these tasks.",
"imagenIncompatibleGenerationMode": "Google {{model}} supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.", "imagenIncompatibleGenerationMode": "Google {{model}} supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.",
"chatGPT4oIncompatibleGenerationMode": "ChatGPT 4o supports Text to Image and Image to Image only. Use other models Inpainting and Outpainting tasks.", "chatGPT4oIncompatibleGenerationMode": "ChatGPT 4o supports Text to Image and Image to Image only. Use other models Inpainting and Outpainting tasks.",
"fluxKontextIncompatibleGenerationMode": "Flux Kontext supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.", "fluxKontextIncompatibleGenerationMode": "FLUX Kontext does not support generation from images placed on the canvas. Re-try using the Reference Image section and disable any Raster Layers.",
"problemUnpublishingWorkflow": "Problem Unpublishing Workflow", "problemUnpublishingWorkflow": "Problem Unpublishing Workflow",
"problemUnpublishingWorkflowDescription": "There was a problem unpublishing the workflow. Please try again.", "problemUnpublishingWorkflowDescription": "There was a problem unpublishing the workflow. Please try again.",
"workflowUnpublished": "Workflow Unpublished", "workflowUnpublished": "Workflow Unpublished",
@@ -1407,7 +1407,7 @@
"sentToUpscale": "Sent to Upscale", "sentToUpscale": "Sent to Upscale",
"promptGenerationStarted": "Prompt generation started", "promptGenerationStarted": "Prompt generation started",
"uploadAndPromptGenerationFailed": "Failed to upload image and generate prompt", "uploadAndPromptGenerationFailed": "Failed to upload image and generate prompt",
"promptExpansionFailed": "Prompt expansion failed" "promptExpansionFailed": "We ran into an issue. Please try prompt expansion again."
}, },
"popovers": { "popovers": {
"clipSkip": { "clipSkip": {
@@ -1962,6 +1962,7 @@
"recalculateRects": "Recalculate Rects", "recalculateRects": "Recalculate Rects",
"clipToBbox": "Clip Strokes to Bbox", "clipToBbox": "Clip Strokes to Bbox",
"outputOnlyMaskedRegions": "Output Only Generated Regions", "outputOnlyMaskedRegions": "Output Only Generated Regions",
"saveAllImagesToGallery": "Save All Images to Gallery",
"addLayer": "Add Layer", "addLayer": "Add Layer",
"duplicate": "Duplicate", "duplicate": "Duplicate",
"moveToFront": "Move to Front", "moveToFront": "Move to Front",
@@ -2330,6 +2331,9 @@
"label": "Preserve Masked Region", "label": "Preserve Masked Region",
"alert": "Preserving Masked Region" "alert": "Preserving Masked Region"
}, },
"saveAllImagesToGallery": {
"alert": "Saving All Images to Gallery"
},
"isolatedStagingPreview": "Isolated Staging Preview", "isolatedStagingPreview": "Isolated Staging Preview",
"isolatedPreview": "Isolated Preview", "isolatedPreview": "Isolated Preview",
"isolatedLayerPreview": "Isolated Layer Preview", "isolatedLayerPreview": "Isolated Layer Preview",
@@ -2358,6 +2362,7 @@
"newGlobalReferenceImage": "New Global Reference Image", "newGlobalReferenceImage": "New Global Reference Image",
"newRegionalReferenceImage": "New Regional Reference Image", "newRegionalReferenceImage": "New Regional Reference Image",
"newControlLayer": "New Control Layer", "newControlLayer": "New Control Layer",
"newResizedControlLayer": "New Resized Control Layer",
"newRasterLayer": "New Raster Layer", "newRasterLayer": "New Raster Layer",
"newInpaintMask": "New Inpaint Mask", "newInpaintMask": "New Inpaint Mask",
"newRegionalGuidance": "New Regional Guidance", "newRegionalGuidance": "New Regional Guidance",
@@ -2375,6 +2380,11 @@
"saveToGallery": "Save To Gallery", "saveToGallery": "Save To Gallery",
"showResultsOn": "Showing Results", "showResultsOn": "Showing Results",
"showResultsOff": "Hiding Results" "showResultsOff": "Hiding Results"
},
"autoSwitch": {
"off": "Off",
"switchOnStart": "On Start",
"switchOnFinish": "On Finish"
} }
}, },
"upscaling": { "upscaling": {
@@ -2550,8 +2560,9 @@
"whatsNew": { "whatsNew": {
"whatsNewInInvoke": "What's New in Invoke", "whatsNewInInvoke": "What's New in Invoke",
"items": [ "items": [
"Inpainting: Per-mask noise levels and denoise limits.", "Generate images faster with new Launchpads and a simplified Generate tab.",
"Canvas: Smarter aspect ratios for SDXL and improved scroll-to-zoom." "Edit with prompts using Flux Kontext Dev.",
"Export to PSD, bulk-hide overlays, organize models & images — all in a reimagined interface built for control."
], ],
"readReleaseNotes": "Read Release Notes", "readReleaseNotes": "Read Release Notes",
"watchRecentReleaseVideos": "Watch Recent Release Videos", "watchRecentReleaseVideos": "Watch Recent Release Videos",
@@ -2560,62 +2571,16 @@
"supportVideos": { "supportVideos": {
"supportVideos": "Support Videos", "supportVideos": "Support Videos",
"gettingStarted": "Getting Started", "gettingStarted": "Getting Started",
"controlCanvas": "Control Canvas",
"watch": "Watch", "watch": "Watch",
"studioSessionsDesc1": "Check out the <StudioSessionsPlaylistLink /> for Invoke deep dives.", "studioSessionsDesc": "Join our <DiscordLink /> to participate in the live sessions and ask questions. Sessions are uploaded to the playlist the following week.",
"studioSessionsDesc2": "Join our <DiscordLink /> to participate in the live sessions and ask questions. Sessions are uploaded to the playlist the following week.",
"videos": { "videos": {
"creatingYourFirstImage": { "gettingStarted": {
"title": "Creating Your First Image", "title": "Getting Started with Invoke",
"description": "Introduction to creating an image from scratch using Invoke's tools." "description": "Complete video series covering everything you need to know to get started with Invoke, from creating your first image to advanced techniques."
}, },
"usingControlLayersAndReferenceGuides": { "studioSessions": {
"title": "Using Control Layers and Reference Guides", "title": "Studio Sessions",
"description": "Learn how to guide your image creation with control layers and reference images." "description": "Deep dive sessions exploring advanced Invoke features, creative workflows, and community discussions."
},
"understandingImageToImageAndDenoising": {
"title": "Understanding Image-to-Image and Denoising",
"description": "Overview of image-to-image transformations and denoising in Invoke."
},
"exploringAIModelsAndConceptAdapters": {
"title": "Exploring AI Models and Concept Adapters",
"description": "Dive into AI models and how to use concept adapters for creative control."
},
"creatingAndComposingOnInvokesControlCanvas": {
"title": "Creating and Composing on Invoke's Control Canvas",
"description": "Learn to compose images using Invoke's control canvas."
},
"upscaling": {
"title": "Upscaling",
"description": "How to upscale images with Invoke's tools to enhance resolution."
},
"howDoIGenerateAndSaveToTheGallery": {
"title": "How Do I Generate and Save to the Gallery?",
"description": "Steps to generate and save images to the gallery."
},
"howDoIEditOnTheCanvas": {
"title": "How Do I Edit on the Canvas?",
"description": "Guide to editing images directly on the canvas."
},
"howDoIDoImageToImageTransformation": {
"title": "How Do I Do Image-to-Image Transformation?",
"description": "Tutorial on performing image-to-image transformations in Invoke."
},
"howDoIUseControlNetsAndControlLayers": {
"title": "How Do I Use Control Nets and Control Layers?",
"description": "Learn to apply control layers and controlnets to your images."
},
"howDoIUseGlobalIPAdaptersAndReferenceImages": {
"title": "How Do I Use Global IP Adapters and Reference Images?",
"description": "Introduction to adding reference images and global IP adapters."
},
"howDoIUseInpaintMasks": {
"title": "How Do I Use Inpaint Masks?",
"description": "How to apply inpaint masks for image correction and variation."
},
"howDoIOutpaint": {
"title": "How Do I Outpaint?",
"description": "Guide to outpainting beyond the original image borders."
} }
} }
} }

View File

@@ -2,8 +2,7 @@ import { Box } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { GlobalHookIsolator } from 'app/components/GlobalHookIsolator'; import { GlobalHookIsolator } from 'app/components/GlobalHookIsolator';
import { GlobalModalIsolator } from 'app/components/GlobalModalIsolator'; import { GlobalModalIsolator } from 'app/components/GlobalModalIsolator';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; import { $didStudioInit, type StudioInitAction } from 'app/hooks/useStudioInitAction';
import { $globalIsLoading } from 'app/store/nanostores/globalIsLoading';
import type { PartialAppConfig } from 'app/types/invokeai'; import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading'; import Loading from 'common/components/Loading/Loading';
import { useClearStorage } from 'common/hooks/useClearStorage'; import { useClearStorage } from 'common/hooks/useClearStorage';
@@ -20,7 +19,7 @@ interface Props {
} }
const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
const globalIsLoading = useStore($globalIsLoading); const didStudioInit = useStore($didStudioInit);
const clearStorage = useClearStorage(); const clearStorage = useClearStorage();
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
@@ -33,7 +32,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}> <ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden"> <Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
<AppContent /> <AppContent />
{globalIsLoading && <Loading />} {!didStudioInit && <Loading />}
</Box> </Box>
<GlobalHookIsolator config={config} studioInitAction={studioInitAction} /> <GlobalHookIsolator config={config} studioInitAction={studioInitAction} />
<GlobalModalIsolator /> <GlobalModalIsolator />

View File

@@ -1,4 +1,5 @@
import { useGlobalModifiersInit } from '@invoke-ai/ui-library'; import { useGlobalModifiersInit } from '@invoke-ai/ui-library';
import { setupListeners } from '@reduxjs/toolkit/query';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import { useStudioInitAction } from 'app/hooks/useStudioInitAction'; import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus'; import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
@@ -10,6 +11,7 @@ import type { PartialAppConfig } from 'app/types/invokeai';
import { useFocusRegionWatcher } from 'common/hooks/focus'; import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useCloseChakraTooltipsOnDragFix } from 'common/hooks/useCloseChakraTooltipsOnDragFix'; import { useCloseChakraTooltipsOnDragFix } from 'common/hooks/useCloseChakraTooltipsOnDragFix';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys'; import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { useDndMonitor } from 'features/dnd/useDndMonitor';
import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher'; import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher'; import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
@@ -44,6 +46,7 @@ export const GlobalHookIsolator = memo(
useSyncLoggingConfig(); useSyncLoggingConfig();
useCloseChakraTooltipsOnDragFix(); useCloseChakraTooltipsOnDragFix();
useNavigationApi(); useNavigationApi();
useDndMonitor();
// Persistent subscription to the queue counts query - canvas relies on this to know if there are pending // Persistent subscription to the queue counts query - canvas relies on this to know if there are pending
// and/or in progress canvas sessions. // and/or in progress canvas sessions.
@@ -62,6 +65,10 @@ export const GlobalHookIsolator = memo(
dispatch(appStarted()); dispatch(appStarted());
}, [dispatch]); }, [dispatch]);
useEffect(() => {
return setupListeners(dispatch);
}, [dispatch]);
useStudioInitAction(studioInitAction); useStudioInitAction(studioInitAction);
useStarterModelsToast(); useStarterModelsToast();
useSyncQueueStatus(); useSyncQueueStatus();

View File

@@ -1,11 +1,14 @@
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus'; import { useIsRegionFocused } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow';
import { useImageActions } from 'features/gallery/hooks/useImageActions'; import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react'; import { memo } from 'react';
import { useImageDTO } from 'services/api/endpoints/images'; import { useImageDTO } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
@@ -27,59 +30,64 @@ GlobalImageHotkeys.displayName = 'GlobalImageHotkeys';
const GlobalImageHotkeysInternal = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { const GlobalImageHotkeysInternal = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const isGalleryFocused = useIsRegionFocused('gallery'); const isGalleryFocused = useIsRegionFocused('gallery');
const isViewerFocused = useIsRegionFocused('viewer'); const isViewerFocused = useIsRegionFocused('viewer');
const imageActions = useImageActions(imageDTO);
const isStaging = useAppSelector(selectIsStaging); const isFocusOK = isGalleryFocused || isViewerFocused;
const isUpscalingEnabled = useFeatureStatus('upscaling');
const recallAll = useRecallAll(imageDTO);
const recallRemix = useRecallRemix(imageDTO);
const recallPrompts = useRecallPrompts(imageDTO);
const recallSeed = useRecallSeed(imageDTO);
const recallDimensions = useRecallDimensions(imageDTO);
const loadWorkflow = useLoadWorkflow(imageDTO);
useRegisteredHotkeys({ useRegisteredHotkeys({
id: 'loadWorkflow', id: 'loadWorkflow',
category: 'viewer', category: 'viewer',
callback: imageActions.loadWorkflow, callback: loadWorkflow.load,
options: { enabled: isGalleryFocused || isViewerFocused }, options: { enabled: loadWorkflow.isEnabled && isFocusOK },
dependencies: [imageActions.loadWorkflow, isGalleryFocused, isViewerFocused], dependencies: [loadWorkflow, isFocusOK],
}); });
useRegisteredHotkeys({ useRegisteredHotkeys({
id: 'recallAll', id: 'recallAll',
category: 'viewer', category: 'viewer',
callback: imageActions.recallAll, callback: recallAll.recall,
options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) }, options: { enabled: recallAll.isEnabled && isFocusOK },
dependencies: [imageActions.recallAll, isStaging, isGalleryFocused, isViewerFocused], dependencies: [recallAll, isFocusOK],
}); });
useRegisteredHotkeys({ useRegisteredHotkeys({
id: 'recallSeed', id: 'recallSeed',
category: 'viewer', category: 'viewer',
callback: imageActions.recallSeed, callback: recallSeed.recall,
options: { enabled: isGalleryFocused || isViewerFocused }, options: { enabled: recallSeed.isEnabled && isFocusOK },
dependencies: [imageActions.recallSeed, isGalleryFocused, isViewerFocused], dependencies: [recallSeed, isFocusOK],
}); });
useRegisteredHotkeys({ useRegisteredHotkeys({
id: 'recallPrompts', id: 'recallPrompts',
category: 'viewer', category: 'viewer',
callback: imageActions.recallPrompts, callback: recallPrompts.recall,
options: { enabled: isGalleryFocused || isViewerFocused }, options: { enabled: recallPrompts.isEnabled && isFocusOK },
dependencies: [imageActions.recallPrompts, isGalleryFocused, isViewerFocused], dependencies: [recallPrompts, isFocusOK],
}); });
useRegisteredHotkeys({ useRegisteredHotkeys({
id: 'remix', id: 'remix',
category: 'viewer', category: 'viewer',
callback: imageActions.remix, callback: recallRemix.recall,
options: { enabled: isGalleryFocused || isViewerFocused }, options: { enabled: recallRemix.isEnabled && isFocusOK },
dependencies: [imageActions.remix, isGalleryFocused, isViewerFocused], dependencies: [recallRemix, isFocusOK],
}); });
useRegisteredHotkeys({ useRegisteredHotkeys({
id: 'useSize', id: 'useSize',
category: 'viewer', category: 'viewer',
callback: imageActions.recallSize, callback: recallDimensions.recall,
options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) }, options: { enabled: recallDimensions.isEnabled && isFocusOK },
dependencies: [imageActions.recallSize, isStaging, isGalleryFocused, isViewerFocused], dependencies: [recallDimensions, isFocusOK],
});
useRegisteredHotkeys({
id: 'runPostprocessing',
category: 'viewer',
callback: imageActions.upscale,
options: { enabled: isUpscalingEnabled && isViewerFocused },
dependencies: [isUpscalingEnabled, imageDTO, isViewerFocused],
}); });
return null; return null;
}); });

View File

@@ -8,7 +8,7 @@ import { paramsReset } from 'features/controlLayers/store/paramsSlice';
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/util'; import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { sentImageToCanvas } from 'features/gallery/store/actions'; import { sentImageToCanvas } from 'features/gallery/store/actions';
import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers'; import { MetadataUtils } from 'features/metadata/parsing';
import { $hasTemplates } from 'features/nodes/store/nodesSlice'; import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { $isWorkflowLibraryModalOpen } from 'features/nodes/store/workflowLibraryModal'; import { $isWorkflowLibraryModalOpen } from 'features/nodes/store/workflowLibraryModal';
import { import {
@@ -19,7 +19,9 @@ import {
} from 'features/nodes/store/workflowLibrarySlice'; } from 'features/nodes/store/workflowLibrarySlice';
import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice'; import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast'; import { toast } from 'features/toast/toast';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api';
import { LAUNCHPAD_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
@@ -90,6 +92,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
const overrides: Partial<CanvasRasterLayerState> = { const overrides: Partial<CanvasRasterLayerState> = {
objects: [imageObject], objects: [imageObject],
}; };
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
store.dispatch(canvasReset()); store.dispatch(canvasReset());
store.dispatch(rasterLayerAdded({ overrides, isSelected: true })); store.dispatch(rasterLayerAdded({ overrides, isSelected: true }));
store.dispatch(sentImageToCanvas()); store.dispatch(sentImageToCanvas());
@@ -116,23 +119,23 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
const metadata = getImageMetadataResult.value; const metadata = getImageMetadataResult.value;
store.dispatch(canvasReset()); store.dispatch(canvasReset());
// This shows a toast // This shows a toast
await parseAndRecallAllMetadata(metadata, true); await MetadataUtils.recallAll(metadata, store);
}, },
[store, t] [store, t]
); );
const handleLoadWorkflow = useCallback( const handleLoadWorkflow = useCallback(
async (workflowId: string) => { (workflowId: string) => {
// This shows a toast // This shows a toast
await loadWorkflowWithDialog({ loadWorkflowWithDialog({
type: 'library', type: 'library',
data: workflowId, data: workflowId,
onSuccess: () => { onSuccess: () => {
store.dispatch(setActiveTab('workflows')); navigationApi.switchToTab('workflows');
}, },
}); });
}, },
[loadWorkflowWithDialog, store] [loadWorkflowWithDialog]
); );
const handleSelectStylePreset = useCallback( const handleSelectStylePreset = useCallback(
@@ -146,7 +149,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
return; return;
} }
store.dispatch(activeStylePresetIdChanged(stylePresetId)); store.dispatch(activeStylePresetIdChanged(stylePresetId));
store.dispatch(setActiveTab('canvas')); navigationApi.switchToTab('canvas');
toast({ toast({
title: t('toast.stylePresetLoaded'), title: t('toast.stylePresetLoaded'),
status: 'info', status: 'info',
@@ -156,33 +159,35 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
); );
const handleGoToDestination = useCallback( const handleGoToDestination = useCallback(
(destination: StudioDestinationAction['data']['destination']) => { async (destination: StudioDestinationAction['data']['destination']) => {
switch (destination) { switch (destination) {
case 'generation': case 'generation':
// Go to the canvas tab, open the image viewer, and enable send-to-gallery mode // Go to the generate tab, open the launchpad
await navigationApi.focusPanel('generate', LAUNCHPAD_PANEL_ID);
store.dispatch(paramsReset()); store.dispatch(paramsReset());
store.dispatch(activeTabCanvasRightPanelChanged('gallery')); store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
break; break;
case 'canvas': case 'canvas':
// Go to the canvas tab, close the image viewer, and disable send-to-gallery mode // Go to the canvas tab, open the launchpad
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
store.dispatch(canvasReset()); store.dispatch(canvasReset());
break; break;
case 'workflows': case 'workflows':
// Go to the workflows tab // Go to the workflows tab
store.dispatch(setActiveTab('workflows')); navigationApi.switchToTab('workflows');
break; break;
case 'upscaling': case 'upscaling':
// Go to the upscaling tab // Go to the upscaling tab
store.dispatch(setActiveTab('upscaling')); navigationApi.switchToTab('upscaling');
break; break;
case 'viewAllWorkflows': case 'viewAllWorkflows':
// Go to the workflows tab and open the workflow library modal // Go to the workflows tab and open the workflow library modal
store.dispatch(setActiveTab('workflows')); navigationApi.switchToTab('workflows');
$isWorkflowLibraryModalOpen.set(true); $isWorkflowLibraryModalOpen.set(true);
break; break;
case 'viewAllWorkflowsRecommended': case 'viewAllWorkflowsRecommended':
// Go to the workflows tab and open the workflow library modal with the recommended workflows view // Go to the workflows tab and open the workflow library modal with the recommended workflows view
store.dispatch(setActiveTab('workflows')); navigationApi.switchToTab('workflows');
$isWorkflowLibraryModalOpen.set(true); $isWorkflowLibraryModalOpen.set(true);
store.dispatch(workflowLibraryViewChanged('defaults')); store.dispatch(workflowLibraryViewChanged('defaults'));
store.dispatch(workflowLibraryTagsReset()); store.dispatch(workflowLibraryTagsReset());
@@ -194,7 +199,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
break; break;
case 'viewAllStylePresets': case 'viewAllStylePresets':
// Go to the canvas tab and open the style presets menu // Go to the canvas tab and open the style presets menu
store.dispatch(setActiveTab('canvas')); navigationApi.switchToTab('canvas');
$isStylePresetsMenuOpen.set(true); $isStylePresetsMenuOpen.set(true);
break; break;
} }

View File

@@ -1,6 +1,6 @@
import { isAnyOf } from '@reduxjs/toolkit'; import { isAnyOf } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectListImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images'; import { imagesApi } from 'services/api/endpoints/images';
@@ -20,7 +20,7 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
const board_id = selectSelectedBoardId(state); const board_id = selectSelectedBoardId(state);
const queryArgs = { ...selectListImageNamesQueryArgs(state), board_id }; const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id };
// wait until the board has some images - maybe it already has some from a previous fetch // wait until the board has some images - maybe it already has some from a previous fetch
// must use getState() to ensure we do not have stale state // must use getState() to ensure we do not have stale state

View File

@@ -1,14 +1,28 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { bboxSyncedToOptimalDimension } from 'features/controlLayers/store/canvasSlice'; import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { loraDeleted } from 'features/controlLayers/store/lorasSlice'; import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
import { modelChanged, vaeSelected } from 'features/controlLayers/store/paramsSlice'; import { modelChanged, syncedToOptimalDimension, vaeSelected } from 'features/controlLayers/store/paramsSlice';
import { selectBboxModelBase } from 'features/controlLayers/store/selectors'; import { refImageModelChanged, selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice';
import {
selectAllEntitiesOfType,
selectBboxModelBase,
selectCanvasSlice,
} from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { modelSelected } from 'features/parameters/store/actions'; import { modelSelected } from 'features/parameters/store/actions';
import { zParameterModel } from 'features/parameters/types/parameterSchemas'; import { zParameterModel } from 'features/parameters/types/parameterSchemas';
import { toast } from 'features/toast/toast'; import { toast } from 'features/toast/toast';
import { t } from 'i18next'; import { t } from 'i18next';
import { selectGlobalRefImageModels, selectRegionalRefImageModels } from 'services/api/hooks/modelsByType';
import type { AnyModelConfig } from 'services/api/types';
import {
isChatGPT4oModelConfig,
isFluxKontextApiModelConfig,
isFluxKontextModelConfig,
isFluxReduxModelConfig,
} from 'services/api/types';
const log = logger('models'); const log = logger('models');
@@ -25,9 +39,8 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
} }
const newModel = result.data; const newModel = result.data;
const newBase = newModel.base;
const newBaseModel = newModel.base; const didBaseModelChange = state.params.model?.base !== newBase;
const didBaseModelChange = state.params.model?.base !== newBaseModel;
if (didBaseModelChange) { if (didBaseModelChange) {
// we may need to reset some incompatible submodels // we may need to reset some incompatible submodels
@@ -35,7 +48,7 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
// handle incompatible loras // handle incompatible loras
state.loras.loras.forEach((lora) => { state.loras.loras.forEach((lora) => {
if (lora.model.base !== newBaseModel) { if (lora.model.base !== newBase) {
dispatch(loraDeleted({ id: lora.id })); dispatch(loraDeleted({ id: lora.id }));
modelsCleared += 1; modelsCleared += 1;
} }
@@ -43,20 +56,82 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
// handle incompatible vae // handle incompatible vae
const { vae } = state.params; const { vae } = state.params;
if (vae && vae.base !== newBaseModel) { if (vae && vae.base !== newBase) {
dispatch(vaeSelected(null)); dispatch(vaeSelected(null));
modelsCleared += 1; modelsCleared += 1;
} }
// handle incompatible controlnets // Handle incompatible reference image models - switch to first compatible model, with some smart logic
// state.canvas.present.controlAdapters.entities.forEach((ca) => { // to choose the best available model based on the new main model.
// if (ca.model?.base !== newBaseModel) { const allRefImageModels = selectGlobalRefImageModels(state).filter(({ base }) => base === newBase);
// modelsCleared += 1;
// if (ca.isEnabled) { let newGlobalRefImageModel = null;
// dispatch(entityIsEnabledToggled({ entityIdentifier: { id: ca.id, type: 'control_adapter' } }));
// } // Certain models require the ref image model to be the same as the main model - others just need a matching
// } // base. Helper to grab the first exact match or the first available model if no exact match is found.
// }); const exactMatchOrFirst = <T extends AnyModelConfig>(candidates: T[]): T | null =>
candidates.find(({ key }) => key === newModel.key) ?? candidates[0] ?? null;
// The only way we can differentiate between FLUX and FLUX Kontext is to check for "kontext" in the name
if (newModel.base === 'flux' && newModel.name.toLowerCase().includes('kontext')) {
const fluxKontextDevModels = allRefImageModels.filter(isFluxKontextModelConfig);
newGlobalRefImageModel = exactMatchOrFirst(fluxKontextDevModels);
} else if (newModel.base === 'chatgpt-4o') {
const chatGPT4oModels = allRefImageModels.filter(isChatGPT4oModelConfig);
newGlobalRefImageModel = exactMatchOrFirst(chatGPT4oModels);
} else if (newModel.base === 'flux-kontext') {
const fluxKontextApiModels = allRefImageModels.filter(isFluxKontextApiModelConfig);
newGlobalRefImageModel = exactMatchOrFirst(fluxKontextApiModels);
} else if (newModel.base === 'flux') {
const fluxReduxModels = allRefImageModels.filter(isFluxReduxModelConfig);
newGlobalRefImageModel = fluxReduxModels[0] ?? null;
} else {
newGlobalRefImageModel = allRefImageModels[0] ?? null;
}
// All ref image entities are updated to use the same new model
const refImageEntities = selectReferenceImageEntities(state);
for (const entity of refImageEntities) {
const shouldUpdateModel =
(entity.config.model && entity.config.model.base !== newBase) ||
(!entity.config.model && newGlobalRefImageModel);
if (shouldUpdateModel) {
dispatch(
refImageModelChanged({
id: entity.id,
modelConfig: newGlobalRefImageModel,
})
);
modelsCleared += 1;
}
}
// For regional guidance, there is no smart logic - we just pick the first available model.
const newRegionalRefImageModel = selectRegionalRefImageModels(state)[0] ?? null;
// All regional guidance entities are updated to use the same new model.
const canvasState = selectCanvasSlice(state);
const canvasRegionalGuidanceEntities = selectAllEntitiesOfType(canvasState, 'regional_guidance');
for (const entity of canvasRegionalGuidanceEntities) {
for (const refImage of entity.referenceImages) {
// Only change the model if the current one is not compatible with the new base model.
const shouldUpdateModel =
(refImage.config.model && refImage.config.model.base !== newBase) ||
(!refImage.config.model && newRegionalRefImageModel);
if (shouldUpdateModel) {
dispatch(
rgRefImageModelChanged({
entityIdentifier: getEntityIdentifier(entity),
referenceImageId: refImage.id,
modelConfig: newRegionalRefImageModel,
})
);
modelsCleared += 1;
}
}
}
if (modelsCleared > 0) { if (modelsCleared > 0) {
toast({ toast({
@@ -71,9 +146,16 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
} }
dispatch(modelChanged({ model: newModel, previousModel: state.params.model })); dispatch(modelChanged({ model: newModel, previousModel: state.params.model }));
const modelBase = selectBboxModelBase(state); const modelBase = selectBboxModelBase(state);
if (!selectIsStaging(state) && modelBase !== state.params.model?.base) {
dispatch(bboxSyncedToOptimalDimension()); if (modelBase !== state.params.model?.base) {
// Sync generate tab settings whenever the model base changes
dispatch(syncedToOptimalDimension());
if (!selectIsStaging(state)) {
// Canvas tab only syncs if not staging
dispatch(bboxSyncedToOptimalDimension());
}
} }
}, },
}); });

View File

@@ -1,7 +1,9 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { isNil } from 'es-toolkit';
import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice'; import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { import {
heightChanged,
setCfgRescaleMultiplier, setCfgRescaleMultiplier,
setCfgScale, setCfgScale,
setGuidance, setGuidance,
@@ -9,6 +11,7 @@ import {
setSteps, setSteps,
vaePrecisionChanged, vaePrecisionChanged,
vaeSelected, vaeSelected,
widthChanged,
} from 'features/controlLayers/store/paramsSlice'; } from 'features/controlLayers/store/paramsSlice';
import { setDefaultSettings } from 'features/parameters/store/actions'; import { setDefaultSettings } from 'features/parameters/store/actions';
import { import {
@@ -23,6 +26,7 @@ import {
zParameterVAEModel, zParameterVAEModel,
} from 'features/parameters/types/parameterSchemas'; } from 'features/parameters/types/parameterSchemas';
import { toast } from 'features/toast/toast'; import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { t } from 'i18next'; import { t } from 'i18next';
import { modelConfigsAdapterSelectors, modelsApi } from 'services/api/endpoints/models'; import { modelConfigsAdapterSelectors, modelsApi } from 'services/api/endpoints/models';
import { isNonRefinerMainModelConfig } from 'services/api/types'; import { isNonRefinerMainModelConfig } from 'services/api/types';
@@ -86,10 +90,16 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
} }
} }
if (cfg_rescale_multiplier) { if (!isNil(cfg_rescale_multiplier)) {
if (isParameterCFGRescaleMultiplier(cfg_rescale_multiplier)) { if (isParameterCFGRescaleMultiplier(cfg_rescale_multiplier)) {
dispatch(setCfgRescaleMultiplier(cfg_rescale_multiplier)); dispatch(setCfgRescaleMultiplier(cfg_rescale_multiplier));
} }
} else {
// Set this to 0 if it doesn't have a default. This value is
// easy to miss in the UI when users are resetting defaults
// and leaving it non-zero could lead to detrimental
// effects.
dispatch(setCfgRescaleMultiplier(0));
} }
if (steps) { if (steps) {
@@ -106,15 +116,24 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
const setSizeOptions = { updateAspectRatio: true, clamp: true }; const setSizeOptions = { updateAspectRatio: true, clamp: true };
const isStaging = selectIsStaging(getState()); const isStaging = selectIsStaging(getState());
if (!isStaging && width) { const activeTab = selectActiveTab(getState());
if (activeTab === 'generate') {
if (isParameterWidth(width)) { if (isParameterWidth(width)) {
dispatch(bboxWidthChanged({ width, ...setSizeOptions })); dispatch(widthChanged({ width, ...setSizeOptions }));
}
if (isParameterHeight(height)) {
dispatch(heightChanged({ height, ...setSizeOptions }));
} }
} }
if (!isStaging && height) { if (activeTab === 'canvas') {
if (isParameterHeight(height)) { if (!isStaging) {
dispatch(bboxHeightChanged({ height, ...setSizeOptions })); if (isParameterWidth(width)) {
dispatch(bboxWidthChanged({ width, ...setSizeOptions }));
}
if (isParameterHeight(height)) {
dispatch(bboxHeightChanged({ height, ...setSizeOptions }));
}
} }
} }

View File

@@ -1,13 +0,0 @@
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
import { atom, computed } from 'nanostores';
import { flushSync } from 'react-dom';
export const $isLayoutLoading = atom(false);
export const setIsLayoutLoading = (isLoading: boolean) => {
flushSync(() => {
$isLayoutLoading.set(isLoading);
});
};
export const $globalIsLoading = computed([$didStudioInit, $isLayoutLoading], (didStudioInit, isLayoutLoading) => {
return !didStudioInit || isLayoutLoading;
});

View File

@@ -11,5 +11,7 @@ export const $false: ReadableAtom<boolean> = atom(false);
/** /**
* A fallback non-writable atom that always returns `true`, used when a nanostores atom is only conditionally available * A fallback non-writable atom that always returns `true`, used when a nanostores atom is only conditionally available
* in a hook or component. * in a hook or component.
*
* @knipignore
*/ */
export const $true: ReadableAtom<boolean> = atom(true); export const $true: ReadableAtom<boolean> = atom(true);

View File

@@ -17,7 +17,6 @@ import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/p
import { refImagesPersistConfig, refImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { refImagesPersistConfig, refImagesSlice } from 'features/controlLayers/store/refImagesSlice';
import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice'; import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice';
import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice';
import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { nodesPersistConfig, nodesSlice, nodesUndoableConfig } from 'features/nodes/store/nodesSlice'; import { nodesPersistConfig, nodesSlice, nodesUndoableConfig } from 'features/nodes/store/nodesSlice';
import { workflowLibraryPersistConfig, workflowLibrarySlice } from 'features/nodes/store/workflowLibrarySlice'; import { workflowLibraryPersistConfig, workflowLibrarySlice } from 'features/nodes/store/workflowLibrarySlice';
@@ -57,7 +56,6 @@ const allReducers = {
[changeBoardModalSlice.name]: changeBoardModalSlice.reducer, [changeBoardModalSlice.name]: changeBoardModalSlice.reducer,
[modelManagerV2Slice.name]: modelManagerV2Slice.reducer, [modelManagerV2Slice.name]: modelManagerV2Slice.reducer,
[queueSlice.name]: queueSlice.reducer, [queueSlice.name]: queueSlice.reducer,
[hrfSlice.name]: hrfSlice.reducer,
[canvasSlice.name]: undoable(canvasSlice.reducer, canvasUndoableConfig), [canvasSlice.name]: undoable(canvasSlice.reducer, canvasUndoableConfig),
[workflowSettingsSlice.name]: workflowSettingsSlice.reducer, [workflowSettingsSlice.name]: workflowSettingsSlice.reducer,
[upscaleSlice.name]: upscaleSlice.reducer, [upscaleSlice.name]: upscaleSlice.reducer,
@@ -103,7 +101,6 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[uiPersistConfig.name]: uiPersistConfig, [uiPersistConfig.name]: uiPersistConfig,
[dynamicPromptsPersistConfig.name]: dynamicPromptsPersistConfig, [dynamicPromptsPersistConfig.name]: dynamicPromptsPersistConfig,
[modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig, [modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig,
[hrfPersistConfig.name]: hrfPersistConfig,
[canvasPersistConfig.name]: canvasPersistConfig, [canvasPersistConfig.name]: canvasPersistConfig,
[workflowSettingsPersistConfig.name]: workflowSettingsPersistConfig, [workflowSettingsPersistConfig.name]: workflowSettingsPersistConfig,
[upscalePersistConfig.name]: upscalePersistConfig, [upscalePersistConfig.name]: upscalePersistConfig,

View File

@@ -8,21 +8,16 @@ const Loading = () => {
return ( return (
<Flex <Flex
position="absolute" position="absolute"
width="100dvw"
height="100dvh"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
bg="#151519" bg="hsl(220 12% 10% / 1)" // base.900
top={0} inset={0}
right={0}
bottom={0}
left={0}
zIndex={99999} zIndex={99999}
> >
<Image src={InvokeLogoWhite} w="8rem" h="8rem" /> <Image src={InvokeLogoWhite} w="8rem" h="8rem" />
<Spinner <Spinner
label="Loading" label="Loading"
color="grey" color="hsl(220 12% 68% / 1)" // base.300
position="absolute" position="absolute"
size="sm" size="sm"
width="24px !important" width="24px !important"

View File

@@ -87,14 +87,10 @@ export const buildGroup = <T extends object>(group: Omit<Group<T>, typeof unique
[uniqueGroupKey]: true, [uniqueGroupKey]: true,
}); });
const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => { export const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => {
return uniqueGroupKey in optionOrGroup && optionOrGroup[uniqueGroupKey] === true; return uniqueGroupKey in optionOrGroup && optionOrGroup[uniqueGroupKey] === true;
}; };
export const isOption = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is T => {
return !(uniqueGroupKey in optionOrGroup);
};
const DefaultOptionComponent = typedMemo(<T extends object>({ option }: { option: T }) => { const DefaultOptionComponent = typedMemo(<T extends object>({ option }: { option: T }) => {
const { getOptionId } = usePickerContext(); const { getOptionId } = usePickerContext();
return <Text fontWeight="bold">{getOptionId(option)}</Text>; return <Text fontWeight="bold">{getOptionId(option)}</Text>;

View File

@@ -0,0 +1,115 @@
import { useStore } from '@nanostores/react';
import { WrappedError } from 'common/util/result';
import type { Atom } from 'nanostores';
import { atom } from 'nanostores';
import { useCallback, useEffect, useMemo, useState } from 'react';
type SuccessState<T> = {
status: 'success';
value: T;
error: null;
};
type ErrorState = {
status: 'error';
value: null;
error: Error;
};
type PendingState = {
status: 'pending';
value: null;
error: null;
};
type IdleState = {
status: 'idle';
value: null;
error: null;
};
export type State<T> = IdleState | PendingState | SuccessState<T> | ErrorState;
type UseAsyncStateOptions = {
immediate?: boolean;
};
type UseAsyncReturn<T> = {
$state: Atom<State<T>>;
trigger: () => Promise<void>;
reset: () => void;
};
export const useAsyncState = <T>(execute: () => Promise<T>, options?: UseAsyncStateOptions): UseAsyncReturn<T> => {
const $state = useState(() =>
atom<State<T>>({
status: 'idle',
value: null,
error: null,
})
)[0];
const trigger = useCallback(async () => {
$state.set({
status: 'pending',
value: null,
error: null,
});
try {
const value = await execute();
$state.set({
status: 'success',
value,
error: null,
});
} catch (error) {
$state.set({
status: 'error',
value: null,
error: WrappedError.wrap(error),
});
}
}, [$state, execute]);
const reset = useCallback(() => {
$state.set({
status: 'idle',
value: null,
error: null,
});
}, [$state]);
useEffect(() => {
if (options?.immediate) {
trigger();
}
}, [options?.immediate, trigger]);
const api = useMemo(
() =>
({
$state,
trigger,
reset,
}) satisfies UseAsyncReturn<T>,
[$state, trigger, reset]
);
return api;
};
type UseAsyncReturnReactive<T> = {
state: State<T>;
trigger: () => Promise<void>;
reset: () => void;
};
export const useAsyncStateReactive = <T>(
execute: () => Promise<T>,
options?: UseAsyncStateOptions
): UseAsyncReturnReactive<T> => {
const { $state, trigger, reset } = useAsyncState(execute, options);
const state = useStore($state);
return { state, trigger, reset };
};

View File

@@ -73,7 +73,7 @@ export const useBoolean = (initialValue: boolean): UseBoolean => {
}; };
}; };
export type UseDisclosure = { type UseDisclosure = {
isOpen: boolean; isOpen: boolean;
open: () => void; open: () => void;
close: () => void; close: () => void;

View File

@@ -1,165 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Adapted from https://github.com/chakra-ui/chakra-ui/blob/v2/packages/hooks/src/use-outside-click.ts
*
* The main change here is to support filtering of outside clicks via a `filter` function.
*
* This lets us work around issues with portals and components like popovers, which typically close on an outside click.
*
* For example, consider a popover that has a custom drop-down component inside it, which uses a portal to render
* the drop-down options. The original outside click handler would close the popover when clicking on the drop-down options,
* because the click is outside the popover - but we expect the popover to stay open in this case.
*
* A filter function like this can fix that:
*
* ```ts
* const filter = (el: HTMLElement) => el.className.includes('chakra-portal') || el.id.includes('react-select')
* ```
*
* This ignores clicks on react-select-based drop-downs and Chakra UI portals and is used as the default filter.
*/
import { useCallback, useEffect, useRef } from 'react';
type FilterFunction = (el: HTMLElement | SVGElement) => boolean;
export function useCallbackRef<T extends (...args: any[]) => any>(
callback: T | undefined,
deps: React.DependencyList = []
) {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
return useCallback(((...args) => callbackRef.current?.(...args)) as T, deps);
}
export interface UseOutsideClickProps {
/**
* Whether the hook is enabled
*/
enabled?: boolean;
/**
* The reference to a DOM element.
*/
ref: React.RefObject<HTMLElement | null>;
/**
* Function invoked when a click is triggered outside the referenced element.
*/
handler?: (e: Event) => void;
/**
* A function that filters the elements that should be considered as outside clicks.
*
* If omitted, a default filter function that ignores clicks in Chakra UI portals and react-select components is used.
*/
filter?: FilterFunction;
}
export const DEFAULT_FILTER: FilterFunction = (el) => {
if (el instanceof SVGElement) {
// SVGElement's type appears to be incorrect. Its className is not a string, which causes `includes` to fail.
// Let's assume that SVG elements with a class name are not part of the portal and should not be filtered.
return false;
}
return el.className.includes('chakra-portal') || el.id.includes('react-select');
};
/**
* Example, used in components like Dialogs and Popovers, so they can close
* when a user clicks outside them.
*/
export function useFilterableOutsideClick(props: UseOutsideClickProps) {
const { ref, handler, enabled = true, filter = DEFAULT_FILTER } = props;
const savedHandler = useCallbackRef(handler);
const stateRef = useRef({
isPointerDown: false,
ignoreEmulatedMouseEvents: false,
});
const state = stateRef.current;
useEffect(() => {
if (!enabled) {
return;
}
const onPointerDown: any = (e: PointerEvent) => {
if (isValidEvent(e, ref, filter)) {
state.isPointerDown = true;
}
};
const onMouseUp: any = (event: MouseEvent) => {
if (state.ignoreEmulatedMouseEvents) {
state.ignoreEmulatedMouseEvents = false;
return;
}
if (state.isPointerDown && handler && isValidEvent(event, ref)) {
state.isPointerDown = false;
savedHandler(event);
}
};
const onTouchEnd = (event: TouchEvent) => {
state.ignoreEmulatedMouseEvents = true;
if (handler && state.isPointerDown && isValidEvent(event, ref)) {
state.isPointerDown = false;
savedHandler(event);
}
};
const doc = getOwnerDocument(ref.current);
doc.addEventListener('mousedown', onPointerDown, true);
doc.addEventListener('mouseup', onMouseUp, true);
doc.addEventListener('touchstart', onPointerDown, true);
doc.addEventListener('touchend', onTouchEnd, true);
return () => {
doc.removeEventListener('mousedown', onPointerDown, true);
doc.removeEventListener('mouseup', onMouseUp, true);
doc.removeEventListener('touchstart', onPointerDown, true);
doc.removeEventListener('touchend', onTouchEnd, true);
};
}, [handler, ref, savedHandler, state, enabled, filter]);
}
function isValidEvent(event: Event, ref: React.RefObject<HTMLElement | null>, filter?: FilterFunction): boolean {
const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement;
if (target) {
const doc = getOwnerDocument(target);
if (!doc.contains(target)) {
return false;
}
}
if (ref.current?.contains(target)) {
return false;
}
// This is the main logic change from the original hook.
if (filter) {
// Check if the click is inside an element matching the filter.
// This is used for portal-awareness or other general exclusion cases.
let currentElement: HTMLElement | null = target;
// Traverse up the DOM tree from the target element.
while (currentElement && currentElement !== document.body) {
if (filter(currentElement)) {
return false;
}
currentElement = currentElement.parentElement;
}
}
// If the click is not inside the ref and not inside a portal, it's a valid outside click.
return true;
}
function getOwnerDocument(node?: Element | null): Document {
return node?.ownerDocument ?? document;
}

View File

@@ -6,7 +6,7 @@ import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrent
import { useInvoke } from 'features/queue/hooks/useInvoke'; import { useInvoke } from 'features/queue/hooks/useInvoke';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api';
import { getFocusedRegion } from './focus'; import { getFocusedRegion } from './focus';
@@ -69,7 +69,7 @@ export const useGlobalHotkeys = () => {
id: 'selectGenerateTab', id: 'selectGenerateTab',
category: 'app', category: 'app',
callback: () => { callback: () => {
dispatch(setActiveTab('generate')); navigationApi.switchToTab('generate');
}, },
dependencies: [dispatch], dependencies: [dispatch],
}); });
@@ -78,7 +78,7 @@ export const useGlobalHotkeys = () => {
id: 'selectCanvasTab', id: 'selectCanvasTab',
category: 'app', category: 'app',
callback: () => { callback: () => {
dispatch(setActiveTab('canvas')); navigationApi.switchToTab('canvas');
}, },
dependencies: [dispatch], dependencies: [dispatch],
}); });
@@ -87,7 +87,7 @@ export const useGlobalHotkeys = () => {
id: 'selectUpscalingTab', id: 'selectUpscalingTab',
category: 'app', category: 'app',
callback: () => { callback: () => {
dispatch(setActiveTab('upscaling')); navigationApi.switchToTab('upscaling');
}, },
dependencies: [dispatch], dependencies: [dispatch],
}); });
@@ -96,7 +96,7 @@ export const useGlobalHotkeys = () => {
id: 'selectWorkflowsTab', id: 'selectWorkflowsTab',
category: 'app', category: 'app',
callback: () => { callback: () => {
dispatch(setActiveTab('workflows')); navigationApi.switchToTab('workflows');
}, },
dependencies: [dispatch], dependencies: [dispatch],
}); });
@@ -105,7 +105,7 @@ export const useGlobalHotkeys = () => {
id: 'selectModelsTab', id: 'selectModelsTab',
category: 'app', category: 'app',
callback: () => { callback: () => {
dispatch(setActiveTab('models')); navigationApi.switchToTab('models');
}, },
options: { options: {
enabled: isModelManagerEnabled, enabled: isModelManagerEnabled,
@@ -117,7 +117,7 @@ export const useGlobalHotkeys = () => {
id: 'selectQueueTab', id: 'selectQueueTab',
category: 'app', category: 'app',
callback: () => { callback: () => {
dispatch(setActiveTab('queue')); navigationApi.switchToTab('queue');
}, },
dependencies: [dispatch, isModelManagerEnabled], dependencies: [dispatch, isModelManagerEnabled],
}); });

View File

@@ -1,132 +0,0 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import type { GroupBase } from 'chakra-react-select';
import { uniq } from 'es-toolkit/compat';
import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import type { ModelIdentifierField } from 'features/nodes/types/common';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships';
import type { AnyModelConfig } from 'services/api/types';
import { useGroupedModelCombobox } from './useGroupedModelCombobox';
type UseRelatedGroupedModelComboboxArg<T extends AnyModelConfig> = {
modelConfigs: T[];
selectedModel?: ModelIdentifierField | null;
onChange: (value: T | null) => void;
getIsDisabled?: (model: T) => boolean;
isLoading?: boolean;
groupByType?: boolean;
};
// Custom hook to overlay the grouped model combobox with related models on top!
// Cleaner than hooking into useGroupedModelCombobox with a flag to enable/disable the related models
// Also allows for related models to be shown conditionally with some pretty simple logic if it ends up as a config flag.
type UseRelatedGroupedModelComboboxReturn = {
value: ComboboxOption | undefined | null;
options: GroupBase<ComboboxOption>[];
onChange: ComboboxOnChange;
placeholder: string;
noOptionsMessage: () => string;
};
const selectSelectedModelKeys = createMemoizedSelector(selectParamsSlice, selectLoRAsSlice, (params, loras) => {
const keys: string[] = [];
const main = params.model;
const vae = params.vae;
const refiner = params.refinerModel;
const controlnet = params.controlLora;
if (main) {
keys.push(main.key);
}
if (vae) {
keys.push(vae.key);
}
if (refiner) {
keys.push(refiner.key);
}
if (controlnet) {
keys.push(controlnet.key);
}
for (const { model } of loras.loras) {
keys.push(model.key);
}
return uniq(keys);
});
export function useRelatedGroupedModelCombobox<T extends AnyModelConfig>({
modelConfigs,
selectedModel,
onChange,
isLoading = false,
getIsDisabled,
groupByType,
}: UseRelatedGroupedModelComboboxArg<T>): UseRelatedGroupedModelComboboxReturn {
const { t } = useTranslation();
const selectedKeys = useAppSelector(selectSelectedModelKeys);
const { relatedKeys } = useGetRelatedModelIdsBatchQuery(selectedKeys, {
selectFromResult: ({ data }) => {
if (!data) {
return { relatedKeys: EMPTY_ARRAY };
}
return { relatedKeys: data };
},
});
// Base grouped options
const base = useGroupedModelCombobox({
modelConfigs,
selectedModel,
onChange,
getIsDisabled,
isLoading,
groupByType,
});
const options = useMemo(() => {
if (relatedKeys.length === 0) {
return base.options;
}
const relatedOptions: ComboboxOption[] = [];
const updatedGroups: GroupBase<ComboboxOption>[] = [];
for (const group of base.options) {
const remainingOptions: ComboboxOption[] = [];
for (const option of group.options) {
if (relatedKeys.includes(option.value)) {
relatedOptions.push({ ...option, label: `* ${option.label}` });
} else {
remainingOptions.push(option);
}
}
if (remainingOptions.length > 0) {
updatedGroups.push({
label: group.label,
options: remainingOptions,
});
}
}
if (relatedOptions.length > 0) {
return [{ label: t('modelManager.relatedModels'), options: relatedOptions }, ...updatedGroups];
} else {
return updatedGroups;
}
}, [base.options, relatedKeys, t]);
return {
...base,
options,
};
}

View File

@@ -1,28 +0,0 @@
import type { Selector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { useAppStore } from 'app/store/storeHooks';
import type { Atom, WritableAtom } from 'nanostores';
import { atom } from 'nanostores';
import { useEffect, useState } from 'react';
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const useSelectorAsAtom = <T extends Selector<RootState, any>>(selector: T): Atom<ReturnType<T>> => {
const store = useAppStore();
const $atom = useState<WritableAtom<ReturnType<T>>>(() => atom<ReturnType<T>>(selector(store.getState())))[0];
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const prev = $atom.get();
const next = selector(store.getState());
if (prev !== next) {
$atom.set(next);
}
});
return () => {
unsubscribe();
};
}, [$atom, selector, store]);
return $atom;
};

View File

@@ -0,0 +1,20 @@
export type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
};
/**
* Create a promise and expose its resolve and reject callbacks.
*/
export const createDeferredPromise = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};

View File

@@ -1,6 +0,0 @@
/**
* Get the keys of an object. This is a wrapper around `Object.keys` that types the result as an array of the keys of the object.
* @param obj The object to get the keys of.
* @returns The keys of the object.
*/
export const objectKeys = <T extends Record<string, unknown>>(obj: T) => Object.keys(obj) as Array<keyof T>;

View File

@@ -57,7 +57,7 @@ export class Err<E> {
* @template T The type of the value in the `Ok` case. * @template T The type of the value in the `Ok` case.
* @template E The type of the error in the `Err` case. * @template E The type of the error in the `Err` case.
*/ */
export type Result<T, E = Error> = Ok<T> | Err<E>; type Result<T, E = Error> = Ok<T> | Err<E>;
/** /**
* Creates a successful result. * Creates a successful result.

View File

@@ -0,0 +1,23 @@
import { Alert, AlertIcon, AlertTitle } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectSaveAllImagesToGallery } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export const CanvasAlertsSaveAllImagesToGallery = memo(() => {
const { t } = useTranslation();
const saveAllImagesToGallery = useAppSelector(selectSaveAllImagesToGallery);
if (!saveAllImagesToGallery) {
return null;
}
return (
<Alert status="info" borderRadius="base" fontSize="sm" shadow="md" w="fit-content">
<AlertIcon />
<AlertTitle>{t('controlLayers.settings.saveAllImagesToGallery.alert')}</AlertTitle>
</Alert>
);
});
CanvasAlertsSaveAllImagesToGallery.displayName = 'CanvasAlertsSaveAllImagesToGallery';

View File

@@ -1,3 +1,4 @@
import type { SpinnerProps } from '@invoke-ai/ui-library';
import { Spinner } from '@invoke-ai/ui-library'; import { Spinner } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
@@ -5,7 +6,7 @@ import { useAllEntityAdapters } from 'features/controlLayers/contexts/EntityAdap
import { computed } from 'nanostores'; import { computed } from 'nanostores';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
export const CanvasBusySpinner = memo(() => { export const CanvasBusySpinner = memo((props: SpinnerProps) => {
const canvasManager = useCanvasManager(); const canvasManager = useCanvasManager();
const allEntityAdapters = useAllEntityAdapters(); const allEntityAdapters = useAllEntityAdapters();
const $isPendingRectCalculation = useMemo( const $isPendingRectCalculation = useMemo(
@@ -21,7 +22,7 @@ export const CanvasBusySpinner = memo(() => {
const isCompositing = useStore(canvasManager.compositor.$isBusy); const isCompositing = useStore(canvasManager.compositor.$isBusy);
if (isRasterizing || isCompositing || isPendingRectCalculation) { if (isRasterizing || isCompositing || isPendingRectCalculation) {
return <Spinner opacity={0.3} />; return <Spinner opacity={0.3} {...props} />;
} }
return null; return null;
}); });

View File

@@ -12,6 +12,10 @@ const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.
const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
type: 'regional_guidance_with_reference_image', type: 'regional_guidance_with_reference_image',
}); });
const addResizedControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
type: 'control_layer',
withResize: true,
});
export const CanvasDropArea = memo(() => { export const CanvasDropArea = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -45,7 +49,6 @@ export const CanvasDropArea = memo(() => {
isDisabled={isBusy} isDisabled={isBusy}
/> />
</GridItem> </GridItem>
<GridItem position="relative"> <GridItem position="relative">
<DndDropTarget <DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget} dndTarget={newCanvasEntityFromImageDndTarget}
@@ -54,6 +57,14 @@ export const CanvasDropArea = memo(() => {
isDisabled={isBusy} isDisabled={isBusy}
/> />
</GridItem> </GridItem>
<GridItem position="relative">
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addResizedControlLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newResizedControlLayer')}
isDisabled={isBusy}
/>
</GridItem>
</Grid> </Grid>
</> </>
); );

View File

@@ -1,27 +0,0 @@
// import { Button, Flex } from '@invoke-ai/ui-library';
// import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
// import { useAddInpaintMaskDenoiseLimit, useAddInpaintMaskNoise } from 'features/controlLayers/hooks/addLayerHooks';
// import { useTranslation } from 'react-i18next';
// import { PiPlusBold } from 'react-icons/pi';
// Removed buttons because denosie limit is not helpful for many architectures
// Users can access with right click menu instead.
// If buttons for noise or new features are deemed important in the future, add them back here.
export const InpaintMaskAddButtons = () => {
// Buttons are temporarily hidden. To restore, uncomment the code below.
return null;
// const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
// const { t } = useTranslation();
// const addInpaintMaskDenoiseLimit = useAddInpaintMaskDenoiseLimit(entityIdentifier);
// const addInpaintMaskNoise = useAddInpaintMaskNoise(entityIdentifier);
// return (
// <Flex w="full" p={2} justifyContent="center">
// <Button size="sm" variant="ghost" leftIcon={<PiPlusBold />} onClick={addInpaintMaskDenoiseLimit}>
// {t('controlLayers.denoiseLimit')}
// </Button>
// <Button size="sm" variant="ghost" leftIcon={<PiPlusBold />} onClick={addInpaintMaskNoise}>
// {t('controlLayers.imageNoise')}
// </Button>
// </Flex>
// );
};

View File

@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
const [useNewGallerySessionDialog] = buildUseBoolean(false); const [useNewGallerySessionDialog] = buildUseBoolean(false);
const [useNewCanvasSessionDialog] = buildUseBoolean(false); const [useNewCanvasSessionDialog] = buildUseBoolean(false);
export const useNewGallerySession = () => { const useNewGallerySession = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const newSessionDialog = useNewGallerySessionDialog(); const newSessionDialog = useNewGallerySessionDialog();
@@ -35,7 +35,7 @@ export const useNewGallerySession = () => {
return { newGallerySessionImmediate, newGallerySessionWithDialog }; return { newGallerySessionImmediate, newGallerySessionWithDialog };
}; };
export const useNewCanvasSession = () => { const useNewCanvasSession = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const newSessionDialog = useNewCanvasSessionDialog(); const newSessionDialog = useNewCanvasSessionDialog();

View File

@@ -4,13 +4,17 @@ import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice';
import { import {
refImageDeleted, refImageDeleted,
refImageIsEnabledToggled, refImageIsEnabledToggled,
selectRefImageEntityIds, selectRefImageEntityIds,
} from 'features/controlLayers/store/refImagesSlice'; } from 'features/controlLayers/store/refImagesSlice';
import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { PiCircleBold, PiCircleFill, PiTrashBold } from 'react-icons/pi'; import { PiCircleBold, PiCircleFill, PiTrashBold, PiWarningBold } from 'react-icons/pi';
import { RefImageWarningTooltipContent } from './RefImageWarningTooltipContent';
const textSx: SystemStyleObject = { const textSx: SystemStyleObject = {
color: 'base.300', color: 'base.300',
@@ -28,6 +32,12 @@ export const RefImageHeader = memo(() => {
); );
const refImageNumber = useAppSelector(selectRefImageNumber); const refImageNumber = useAppSelector(selectRefImageNumber);
const entity = useRefImageEntity(id); const entity = useRefImageEntity(id);
const mainModelConfig = useAppSelector(selectMainModelConfig);
const warnings = useMemo(() => {
return getGlobalReferenceImageWarnings(entity, mainModelConfig);
}, [entity, mainModelConfig]);
const deleteRefImage = useCallback(() => { const deleteRefImage = useCallback(() => {
dispatch(refImageDeleted({ id })); dispatch(refImageDeleted({ id }));
}, [dispatch, id]); }, [dispatch, id]);
@@ -42,6 +52,18 @@ export const RefImageHeader = memo(() => {
Reference Image #{refImageNumber} Reference Image #{refImageNumber}
</Text> </Text>
<Flex alignItems="center" gap={1}> <Flex alignItems="center" gap={1}>
{warnings.length > 0 && (
<IconButton
as="span"
size="sm"
variant="link"
alignSelf="stretch"
aria-label="warnings"
tooltip={<RefImageWarningTooltipContent warnings={warnings} />}
icon={<PiWarningBold />}
colorScheme="warning"
/>
)}
{!entity.isEnabled && ( {!entity.isEnabled && (
<Text fontSize="xs" fontStyle="italic" color="base.400"> <Text fontSize="xs" fontStyle="italic" color="base.400">
Disabled Disabled

View File

@@ -61,7 +61,7 @@ export const RefImageImage = memo(
)} )}
{imageDTO && ( {imageDTO && (
<> <>
<DndImage imageDTO={imageDTO} borderWidth={1} borderStyle="solid" w="full" /> <DndImage imageDTO={imageDTO} borderRadius="base" borderWidth={1} borderStyle="solid" w="full" />
<Flex position="absolute" flexDir="column" top={2} insetInlineEnd={2} gap={1}> <Flex position="absolute" flexDir="column" top={2} insetInlineEnd={2} gap={1}>
<DndImageIcon <DndImageIcon
onClick={handleResetControlImage} onClick={handleResetControlImage}

View File

@@ -1,9 +1,12 @@
import { Button, Collapse, Divider, Flex } from '@invoke-ai/ui-library'; import { Button, Collapse, Divider, Flex, IconButton } from '@invoke-ai/ui-library';
import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { RefImagePreview } from 'features/controlLayers/components/RefImage/RefImagePreview'; import { RefImagePreview } from 'features/controlLayers/components/RefImage/RefImagePreview';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { useNewGlobalReferenceImageFromBbox } from 'features/controlLayers/hooks/saveCanvasHooks';
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { import {
refImageAdded, refImageAdded,
selectIsRefImagePanelOpen, selectIsRefImagePanelOpen,
@@ -13,8 +16,10 @@ import {
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { PiUploadBold } from 'react-icons/pi'; import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold, PiUploadBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
import { RefImageHeader } from './RefImageHeader'; import { RefImageHeader } from './RefImageHeader';
@@ -78,6 +83,7 @@ MaxRefImages.displayName = 'MaxRefImages';
const AddRefImageDropTargetAndButton = memo(() => { const AddRefImageDropTargetAndButton = memo(() => {
const { dispatch, getState } = useAppStore(); const { dispatch, getState } = useAppStore();
const tab = useAppSelector(selectActiveTab);
const uploadOptions = useMemo( const uploadOptions = useMemo(
() => () =>
@@ -95,7 +101,7 @@ const AddRefImageDropTargetAndButton = memo(() => {
const uploadApi = useImageUploadButton(uploadOptions); const uploadApi = useImageUploadButton(uploadOptions);
return ( return (
<> <Flex gap={1} h="full" w="full">
<Button <Button
position="relative" position="relative"
size="sm" size="sm"
@@ -112,7 +118,31 @@ const AddRefImageDropTargetAndButton = memo(() => {
<input {...uploadApi.getUploadInputProps()} /> <input {...uploadApi.getUploadInputProps()} />
<DndDropTarget label="Drop" dndTarget={addGlobalReferenceImageDndTarget} dndTargetData={dndTargetData} /> <DndDropTarget label="Drop" dndTarget={addGlobalReferenceImageDndTarget} dndTargetData={dndTargetData} />
</Button> </Button>
</> {tab === 'canvas' && (
<CanvasManagerProviderGate>
<BboxButton />
</CanvasManagerProviderGate>
)}
</Flex>
);
});
const BboxButton = memo(() => {
const { t } = useTranslation();
const isBusy = useCanvasIsBusySafe();
const newGlobalReferenceImageFromBbox = useNewGlobalReferenceImageFromBbox();
return (
<IconButton
size="lg"
variant="outline"
h="full"
icon={<PiBoundingBoxBold />}
onClick={newGlobalReferenceImageFromBbox}
isDisabled={isBusy}
aria-label={t('controlLayers.pullBboxIntoReferenceImage')}
tooltip={t('controlLayers.pullBboxIntoReferenceImage')}
/>
); );
}); });
AddRefImageDropTargetAndButton.displayName = 'AddRefImageDropTargetAndButton'; AddRefImageDropTargetAndButton.displayName = 'AddRefImageDropTargetAndButton';

View File

@@ -1,25 +1,35 @@
import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library'; import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox'; import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { selectBase } from 'features/controlLayers/store/paramsSlice'; import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice';
import { areBasesCompatibleForRefImage } from 'features/controlLayers/store/validators';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useGlobalReferenceImageModels } from 'services/api/hooks/modelsByType'; import { useGlobalReferenceImageModels } from 'services/api/hooks/modelsByType';
import type { AnyModelConfig, ApiModelConfig, FLUXReduxModelConfig, IPAdapterModelConfig } from 'services/api/types'; import type {
ChatGPT4oModelConfig,
FLUXKontextModelConfig,
FLUXReduxModelConfig,
IPAdapterModelConfig,
} from 'services/api/types';
type Props = { type Props = {
modelKey: string | null; modelKey: string | null;
onChangeModel: (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => void; onChangeModel: (
modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ChatGPT4oModelConfig | FLUXKontextModelConfig
) => void;
}; };
export const RefImageModel = memo(({ modelKey, onChangeModel }: Props) => { export const RefImageModel = memo(({ modelKey, onChangeModel }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const currentBaseModel = useAppSelector(selectBase); const mainModelConfig = useAppSelector(selectMainModelConfig);
const [modelConfigs, { isLoading }] = useGlobalReferenceImageModels(); const [modelConfigs, { isLoading }] = useGlobalReferenceImageModels();
const selectedModel = useMemo(() => modelConfigs.find((m) => m.key === modelKey), [modelConfigs, modelKey]); const selectedModel = useMemo(() => modelConfigs.find((m) => m.key === modelKey), [modelConfigs, modelKey]);
const _onChangeModel = useCallback( const _onChangeModel = useCallback(
(modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig | null) => { (
modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ChatGPT4oModelConfig | FLUXKontextModelConfig | null
) => {
if (!modelConfig) { if (!modelConfig) {
return; return;
} }
@@ -29,12 +39,10 @@ export const RefImageModel = memo(({ modelKey, onChangeModel }: Props) => {
); );
const getIsDisabled = useCallback( const getIsDisabled = useCallback(
(model: AnyModelConfig): boolean => { (model: IPAdapterModelConfig | FLUXReduxModelConfig | ChatGPT4oModelConfig | FLUXKontextModelConfig): boolean => {
const hasMainModel = Boolean(currentBaseModel); return !areBasesCompatibleForRefImage(mainModelConfig, model);
const hasSameBase = currentBaseModel === model.base;
return !hasMainModel || !hasSameBase;
}, },
[currentBaseModel] [mainModelConfig]
); );
const { options, value, onChange, noOptionsMessage } = useGroupedModelCombobox({ const { options, value, onChange, noOptionsMessage } = useGroupedModelCombobox({
@@ -47,7 +55,11 @@ export const RefImageModel = memo(({ modelKey, onChangeModel }: Props) => {
return ( return (
<Tooltip label={selectedModel?.description}> <Tooltip label={selectedModel?.description}>
<FormControl isInvalid={!value || currentBaseModel !== selectedModel?.base} w="full" minW={0}> <FormControl
isInvalid={!value || !areBasesCompatibleForRefImage(mainModelConfig, selectedModel)}
w="full"
minW={0}
>
<Combobox <Combobox
options={options} options={options}
placeholder={t('common.placeholderSelectAModel')} placeholder={t('common.placeholderSelectAModel')}

View File

@@ -1,26 +1,40 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library'; import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, Icon, IconButton, Image, Skeleton, Text } from '@invoke-ai/ui-library'; import { Flex, Icon, IconButton, Image, Skeleton, Text, Tooltip } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query'; import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { round } from 'es-toolkit/compat'; import { round } from 'es-toolkit/compat';
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice';
import { import {
refImageSelected, refImageSelected,
selectIsRefImagePanelOpen, selectIsRefImagePanelOpen,
selectSelectedRefEntityId, selectSelectedRefEntityId,
} from 'features/controlLayers/store/refImagesSlice'; } from 'features/controlLayers/store/refImagesSlice';
import { isIPAdapterConfig } from 'features/controlLayers/store/types'; import { isIPAdapterConfig } from 'features/controlLayers/store/types';
import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi'; import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { RefImageWarningTooltipContent } from './RefImageWarningTooltipContent';
const baseSx: SystemStyleObject = { const baseSx: SystemStyleObject = {
'&[data-is-open="true"]': { '&[data-is-open="true"]': {
borderColor: 'invokeBlue.300', borderColor: 'invokeBlue.300',
}, },
'&[data-is-disabled="true"]': { '&[data-is-disabled="true"]': {
opacity: 0.4, img: {
opacity: 0.4,
filter: 'grayscale(100%)',
},
},
'&[data-is-error="true"]': {
borderColor: 'error.500',
img: {
opacity: 0.4,
filter: 'grayscale(100%)',
},
}, },
}; };
@@ -39,9 +53,6 @@ const getImageSxWithWeight = (weight: number): SystemStyleObject => {
return { return {
...baseSx, ...baseSx,
'&[data-is-disabled="true"]': {
opacity: 0.4,
},
_after: { _after: {
content: '""', content: '""',
position: 'absolute', position: 'absolute',
@@ -57,6 +68,7 @@ export const RefImagePreview = memo(() => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const id = useRefImageIdContext(); const id = useRefImageIdContext();
const entity = useRefImageEntity(id); const entity = useRefImageEntity(id);
const mainModelConfig = useAppSelector(selectMainModelConfig);
const selectedEntityId = useAppSelector(selectSelectedRefEntityId); const selectedEntityId = useAppSelector(selectSelectedRefEntityId);
const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen); const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen);
const [showWeightDisplay, setShowWeightDisplay] = useState(false); const [showWeightDisplay, setShowWeightDisplay] = useState(false);
@@ -82,6 +94,10 @@ export const RefImagePreview = memo(() => {
}; };
}, [entity.config]); }, [entity.config]);
const warnings = useMemo(() => {
return getGlobalReferenceImageWarnings(entity, mainModelConfig);
}, [entity, mainModelConfig]);
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(refImageSelected({ id })); dispatch(refImageSelected({ id }));
}, [dispatch, id]); }, [dispatch, id]);
@@ -109,73 +125,76 @@ export const RefImagePreview = memo(() => {
); );
} }
return ( return (
<Flex <Tooltip label={warnings.length > 0 ? <RefImageWarningTooltipContent warnings={warnings} /> : undefined}>
position="relative" <Flex
borderWidth={1} position="relative"
borderStyle="solid" borderWidth={1}
borderRadius="base" borderStyle="solid"
aspectRatio="1/1" borderRadius="base"
maxW="full"
maxH="full"
flexShrink={0}
sx={sx}
data-is-open={selectedEntityId === id && isPanelOpen}
data-is-error={!entity.config.model}
data-is-disabled={!entity.isEnabled}
role="button"
onClick={onClick}
cursor="pointer"
>
<Image
src={imageDTO?.thumbnail_url}
objectFit="contain"
aspectRatio="1/1" aspectRatio="1/1"
height={imageDTO?.height}
fallback={<Skeleton h="full" aspectRatio="1/1" />}
maxW="full" maxW="full"
maxH="full" maxH="full"
borderRadius="base" flexShrink={0}
/> sx={sx}
{isIPAdapterConfig(entity.config) && ( data-is-open={selectedEntityId === id && isPanelOpen}
<Flex data-is-error={warnings.length > 0}
position="absolute" data-is-disabled={!entity.isEnabled}
inset={0} role="button"
fontWeight="semibold" onClick={onClick}
alignItems="center" cursor="pointer"
justifyContent="center" overflow="hidden"
zIndex={1} >
data-visible={showWeightDisplay} <Image
sx={weightDisplaySx} src={imageDTO?.thumbnail_url}
> objectFit="contain"
<Text filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"> aspectRatio="1/1"
{`${round(entity.config.weight * 100, 2)}%`} height={imageDTO?.height}
</Text> fallback={<Skeleton h="full" aspectRatio="1/1" />}
</Flex> maxW="full"
)} maxH="full"
{!entity.isEnabled ? (
<Icon
position="absolute"
top="50%"
left="50%"
transform="translateX(-50%) translateY(-50%)"
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
color="base.100"
boxSize={6}
as={PiEyeSlashBold}
/> />
) : !entity.config.model ? ( {isIPAdapterConfig(entity.config) && (
<Icon <Flex
position="absolute" position="absolute"
top="50%" inset={0}
left="50%" fontWeight="semibold"
transform="translateX(-50%) translateY(-50%)" alignItems="center"
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))" justifyContent="center"
color="error.500" zIndex={1}
boxSize={6} data-visible={showWeightDisplay}
as={PiExclamationMarkBold} sx={weightDisplaySx}
/> >
) : null} <Text filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))">
</Flex> {`${round(entity.config.weight * 100, 2)}%`}
</Text>
</Flex>
)}
{!entity.isEnabled && (
<Icon
position="absolute"
top="50%"
left="50%"
transform="translateX(-50%) translateY(-50%)"
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
color="base.300"
boxSize={8}
as={PiEyeSlashBold}
/>
)}
{entity.isEnabled && warnings.length > 0 && (
<Icon
position="absolute"
top="50%"
left="50%"
transform="translateX(-50%) translateY(-50%)"
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
color="error.500"
boxSize={12}
as={PiExclamationMarkBold}
/>
)}
</Flex>
</Tooltip>
); );
}); });
RefImagePreview.displayName = 'RefImagePreview'; RefImagePreview.displayName = 'RefImagePreview';

View File

@@ -38,7 +38,13 @@ import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types'; import type {
ChatGPT4oModelConfig,
FLUXKontextModelConfig,
FLUXReduxModelConfig,
ImageDTO,
IPAdapterModelConfig,
} from 'services/api/types';
import { RefImageImage } from './RefImageImage'; import { RefImageImage } from './RefImageImage';
@@ -84,7 +90,7 @@ const RefImageSettingsContent = memo(() => {
); );
const onChangeModel = useCallback( const onChangeModel = useCallback(
(modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => { (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ChatGPT4oModelConfig | FLUXKontextModelConfig) => {
dispatch(refImageModelChanged({ id, modelConfig })); dispatch(refImageModelChanged({ id, modelConfig }));
}, },
[dispatch, id] [dispatch, id]

View File

@@ -0,0 +1,18 @@
import { Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
import { upperFirst } from 'es-toolkit/compat';
import { useTranslation } from 'react-i18next';
export const RefImageWarningTooltipContent = ({ warnings }: { warnings: string[] }) => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text fontWeight="semibold">Invalid Reference Image:</Text>
<UnorderedList>
{warnings.map((tKey) => (
<ListItem key={tKey}>{upperFirst(t(tKey))}</ListItem>
))}
</UnorderedList>
</Flex>
);
};

View File

@@ -26,6 +26,7 @@ import { CanvasSettingsPreserveMaskCheckbox } from 'features/controlLayers/compo
import { CanvasSettingsPressureSensitivityCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity'; import { CanvasSettingsPressureSensitivityCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity';
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton'; import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
import { CanvasSettingsRuleOfThirdsSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsRuleOfThirdsGuideSwitch'; import { CanvasSettingsRuleOfThirdsSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsRuleOfThirdsGuideSwitch';
import { CanvasSettingsSaveAllImagesToGalleryCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsSaveAllImagesToGalleryCheckbox';
import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch'; import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch';
import { CanvasSettingsShowProgressOnCanvas } from 'features/controlLayers/components/Settings/CanvasSettingsShowProgressOnCanvasSwitch'; import { CanvasSettingsShowProgressOnCanvas } from 'features/controlLayers/components/Settings/CanvasSettingsShowProgressOnCanvasSwitch';
import { memo } from 'react'; import { memo } from 'react';
@@ -61,6 +62,7 @@ export const CanvasSettingsPopover = memo(() => {
<CanvasSettingsPreserveMaskCheckbox /> <CanvasSettingsPreserveMaskCheckbox />
<CanvasSettingsClipToBboxCheckbox /> <CanvasSettingsClipToBboxCheckbox />
<CanvasSettingsOutputOnlyMaskedRegionsCheckbox /> <CanvasSettingsOutputOnlyMaskedRegionsCheckbox />
<CanvasSettingsSaveAllImagesToGalleryCheckbox />
</Flex> </Flex>
<Divider /> <Divider />

View File

@@ -0,0 +1,25 @@
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectSaveAllImagesToGallery,
settingsSaveAllImagesToGalleryToggled,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const CanvasSettingsSaveAllImagesToGalleryCheckbox = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const saveAllImagesToGallery = useAppSelector(selectSaveAllImagesToGallery);
const onChange = useCallback(() => {
dispatch(settingsSaveAllImagesToGalleryToggled());
}, [dispatch]);
return (
<FormControl w="full">
<FormLabel flexGrow={1}>{t('controlLayers.saveAllImagesToGallery')}</FormLabel>
<Checkbox isChecked={saveAllImagesToGallery} onChange={onChange} />
</FormControl>
);
});
CanvasSettingsSaveAllImagesToGalleryCheckbox.displayName = 'CanvasSettingsSaveAllImagesToGalleryCheckbox';

View File

@@ -1,5 +1,4 @@
import { Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; import { Button, Flex, Grid, Text } from '@invoke-ai/ui-library';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { navigationApi } from 'features/ui/layouts/navigation-api'; import { navigationApi } from 'features/ui/layouts/navigation-api';
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared'; import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
@@ -7,44 +6,41 @@ import { useTranslation } from 'react-i18next';
import { InitialStateMainModelPicker } from './InitialStateMainModelPicker'; import { InitialStateMainModelPicker } from './InitialStateMainModelPicker';
import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference'; import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference';
import { LaunchpadContainer } from './LaunchpadContainer';
import { LaunchpadEditImageButton } from './LaunchpadEditImageButton'; import { LaunchpadEditImageButton } from './LaunchpadEditImageButton';
import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton'; import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton';
import { LaunchpadUseALayoutImageButton } from './LaunchpadUseALayoutImageButton'; import { LaunchpadUseALayoutImageButton } from './LaunchpadUseALayoutImageButton';
export const CanvasLaunchpadPanel = memo(() => { export const CanvasLaunchpadPanel = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const { tab } = useAutoLayoutContext();
const focusCanvas = useCallback(() => { const focusCanvas = useCallback(() => {
navigationApi.focusPanelInTab(tab, WORKSPACE_PANEL_ID); navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
}, [tab]); }, []);
return ( return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}> <LaunchpadContainer heading={t('ui.launchpad.canvasTitle')}>
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh"> <Grid gridTemplateColumns="1fr 1fr" gap={8}>
<Heading mb={4}>{t('ui.launchpad.canvasTitle')}</Heading> <InitialStateMainModelPicker />
<Flex flexDir="column" gap={8}> <Flex flexDir="column" gap={2} justifyContent="center">
<Grid gridTemplateColumns="1fr 1fr" gap={8}> <Text>
<InitialStateMainModelPicker /> {t('ui.launchpad.modelGuideText')}{' '}
<Flex flexDir="column" gap={2} justifyContent="center"> <Button
<Text> as="a"
{t('ui.launchpad.modelGuideText')}{' '} variant="link"
<Button href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide"
as="a" target="_blank"
variant="link" rel="noopener noreferrer"
href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide" size="sm"
size="sm" >
> {t('ui.launchpad.modelGuideLink')}
{t('ui.launchpad.modelGuideLink')} </Button>
</Button> </Text>
</Text>
</Flex>
</Grid>
<LaunchpadGenerateFromTextButton extraAction={focusCanvas} />
<LaunchpadAddStyleReference extraAction={focusCanvas} />
<LaunchpadEditImageButton extraAction={focusCanvas} />
<LaunchpadUseALayoutImageButton extraAction={focusCanvas} />
</Flex> </Flex>
</Flex> </Grid>
</Flex> <LaunchpadGenerateFromTextButton extraAction={focusCanvas} />
<LaunchpadAddStyleReference extraAction={focusCanvas} />
<LaunchpadEditImageButton extraAction={focusCanvas} />
<LaunchpadUseALayoutImageButton extraAction={focusCanvas} />
</LaunchpadContainer>
); );
}); });
CanvasLaunchpadPanel.displayName = 'CanvasLaunchpadPanel'; CanvasLaunchpadPanel.displayName = 'CanvasLaunchpadPanel';

View File

@@ -1,52 +1,48 @@
import { Alert, Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; import { Alert, Button, Flex, Grid, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker'; import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker';
import { LaunchpadAddStyleReference } from 'features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference'; import { LaunchpadAddStyleReference } from 'features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference';
import { setActiveTab } from 'features/ui/store/uiSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { LaunchpadContainer } from './LaunchpadContainer';
import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton'; import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton';
export const GenerateLaunchpadPanel = memo(() => { export const GenerateLaunchpadPanel = memo(() => {
const dispatch = useAppDispatch();
const newCanvasSession = useCallback(() => { const newCanvasSession = useCallback(() => {
dispatch(setActiveTab('canvas')); navigationApi.switchToTab('canvas');
}, [dispatch]); }, []);
return ( return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}> <LaunchpadContainer heading="Generate images from text prompts.">
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh"> <Grid gridTemplateColumns="1fr 1fr" gap={8}>
<Heading mb={4}>Generate images from text prompts.</Heading> <InitialStateMainModelPicker />
<Flex flexDir="column" gap={8}> <Flex flexDir="column" gap={2} justifyContent="center">
<Grid gridTemplateColumns="1fr 1fr" gap={8}> <Text>
<InitialStateMainModelPicker /> Want to learn what prompts work best for each model?{' '}
<Flex flexDir="column" gap={2} justifyContent="center"> <Button
<Text> as="a"
Want to learn what prompts work best for each model?{' '} variant="link"
<Button href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide"
as="a" target="_blank"
variant="link" rel="noopener noreferrer"
href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide" size="sm"
size="sm" >
> Check out our Model Guide.
Check out our Model Guide.
</Button>
</Text>
</Flex>
</Grid>
<LaunchpadGenerateFromTextButton />
<LaunchpadAddStyleReference />
<Alert status="info" borderRadius="base" flexDir="column" gap={2} overflow="unset">
<Text fontSize="md" fontWeight="semibold">
Looking to get more control, edit, and iterate on your images?
</Text>
<Button variant="link" onClick={newCanvasSession}>
Navigate to Canvas for more capabilities.
</Button> </Button>
</Alert> </Text>
</Flex> </Flex>
</Flex> </Grid>
</Flex> <LaunchpadGenerateFromTextButton />
<LaunchpadAddStyleReference />
<Alert status="info" borderRadius="base" flexDir="column" gap={2} overflow="unset">
<Text fontSize="md" fontWeight="semibold">
Looking to get more control, edit, and iterate on your images?
</Text>
<Button variant="link" onClick={newCanvasSession}>
Navigate to Canvas for more capabilities.
</Button>
</Alert>
</LaunchpadContainer>
); );
}); });
GenerateLaunchpadPanel.displayName = 'GenerateLaunchpad'; GenerateLaunchpadPanel.displayName = 'GenerateLaunchpad';

View File

@@ -1,28 +0,0 @@
import type { ButtonGroupProps } from '@invoke-ai/ui-library';
import { Button, ButtonGroup } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/storeHooks';
import { newCanvasFromImage } from 'features/imageActions/actions';
import { memo, useCallback } from 'react';
import type { ImageDTO } from 'services/api/types';
export const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => {
const { getState, dispatch } = useAppStore();
const edit = useCallback(() => {
newCanvasFromImage({
imageDTO,
type: 'raster_layer',
withInpaintMask: true,
getState,
dispatch,
});
}, [dispatch, getState, imageDTO]);
return (
<ButtonGroup isAttached={false} size="sm" {...rest}>
<Button onClick={edit} tooltip="Edit parts of this image with Inpainting">
Edit
</Button>
</ButtonGroup>
);
});
ImageActions.displayName = 'ImageActions';

View File

@@ -0,0 +1,17 @@
import { Flex, Heading } from '@invoke-ai/ui-library';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const LaunchpadContainer = memo((props: PropsWithChildren<{ heading: string }>) => {
return (
<Flex flexDir="column" h="full" w="full" alignItems="center" justifyContent="center" gap={2}>
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768}>
<Heading>{props.heading}</Heading>
<Flex flexDir="column" gap={4}>
{props.children}
</Flex>
</Flex>
</Flex>
);
});
LaunchpadContainer.displayName = 'LaunchpadContainer';

View File

@@ -1,11 +1,10 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton'; import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi'; import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi';
const focusOnPrompt = (el: HTMLElement) => { const focusOnPrompt = () => {
const promptElement = el.querySelector('.positive-prompt-textarea'); const promptElement = document.querySelector('.positive-prompt-textarea');
if (promptElement instanceof HTMLTextAreaElement) { if (promptElement instanceof HTMLTextAreaElement) {
promptElement.focus(); promptElement.focus();
promptElement.select(); promptElement.select();
@@ -13,15 +12,10 @@ const focusOnPrompt = (el: HTMLElement) => {
}; };
export const LaunchpadGenerateFromTextButton = memo((props: { extraAction?: () => void }) => { export const LaunchpadGenerateFromTextButton = memo((props: { extraAction?: () => void }) => {
const { rootRef } = useAutoLayoutContext();
const onClick = useCallback(() => { const onClick = useCallback(() => {
const el = rootRef.current; focusOnPrompt();
if (!el) {
return;
}
focusOnPrompt(el);
props.extraAction?.(); props.extraAction?.();
}, [props, rootRef]); }, [props]);
return ( return (
<LaunchpadButton onClick={onClick} position="relative" gap={8}> <LaunchpadButton onClick={onClick} position="relative" gap={8}>
<Icon as={PiTextAaBold} boxSize={8} color="base.500" /> <Icon as={PiTextAaBold} boxSize={8} color="base.500" />

View File

@@ -1,56 +0,0 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import {
useCanvasSessionContext,
useOutputImageDTO,
useProgressData,
} from 'features/controlLayers/components/SimpleSession/context';
import { ImageActions } from 'features/controlLayers/components/SimpleSession/ImageActions';
import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress';
import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber';
import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import { DndImage } from 'features/dnd/DndImage';
import { memo } from 'react';
import type { S } from 'services/api/types';
type Props = {
item: S['SessionQueueItem'];
number: number;
};
const sx = {
userSelect: 'none',
pos: 'relative',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
h: 'full',
w: 'full',
} satisfies SystemStyleObject;
export const QueueItemPreviewFull = memo(({ item, number }: Props) => {
const ctx = useCanvasSessionContext();
const imageDTO = useOutputImageDTO(item);
const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
return (
<Flex id={getQueueItemElementId(item.item_id)} sx={sx}>
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} />}
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}
{imageDTO && <ImageActions imageDTO={imageDTO} position="absolute" top={1} right={2} />}
<QueueItemNumber number={number} position="absolute" top={1} left={2} />
<QueueItemCircularProgress
itemId={item.item_id}
status={item.status}
position="absolute"
top={1}
right={2}
size={8}
/>
</Flex>
);
});
QueueItemPreviewFull.displayName = 'QueueItemPreviewFull';

View File

@@ -1,5 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library'; import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
useCanvasSessionContext, useCanvasSessionContext,
useOutputImageDTO, useOutputImageDTO,
@@ -10,6 +11,10 @@ import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession
import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage'; import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import {
selectStagingAreaAutoSwitch,
settingsStagingAreaAutoSwitchChanged,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { DndImage } from 'features/dnd/DndImage'; import { DndImage } from 'features/dnd/DndImage';
import { toast } from 'features/toast/toast'; import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
@@ -21,12 +26,12 @@ const sx = {
pos: 'relative', pos: 'relative',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
h: 108,
w: 108,
flexShrink: 0, flexShrink: 0,
aspectRatio: '1/1',
borderWidth: 2, borderWidth: 2,
borderRadius: 'base', borderRadius: 'base',
bg: 'base.900', bg: 'base.900',
overflow: 'hidden',
'&[data-selected="true"]': { '&[data-selected="true"]': {
borderColor: 'invokeBlue.300', borderColor: 'invokeBlue.300',
}, },
@@ -39,23 +44,24 @@ type Props = {
}; };
export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) => { export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) => {
const dispatch = useAppDispatch();
const ctx = useCanvasSessionContext(); const ctx = useCanvasSessionContext();
const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id); const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
const imageDTO = useOutputImageDTO(item); const imageDTO = useOutputImageDTO(item);
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
const onClick = useCallback(() => { const onClick = useCallback(() => {
ctx.$selectedItemId.set(item.item_id); ctx.$selectedItemId.set(item.item_id);
}, [ctx.$selectedItemId, item.item_id]); }, [ctx.$selectedItemId, item.item_id]);
const onDoubleClick = useCallback(() => { const onDoubleClick = useCallback(() => {
const autoSwitch = ctx.$autoSwitch.get();
if (autoSwitch !== 'off') { if (autoSwitch !== 'off') {
ctx.$autoSwitch.set('off'); dispatch(settingsStagingAreaAutoSwitchChanged('off'));
toast({ toast({
title: 'Auto-Switch Disabled', title: 'Auto-Switch Disabled',
}); });
} }
}, [ctx.$autoSwitch]); }, [autoSwitch, dispatch]);
const onLoad = useCallback(() => { const onLoad = useCallback(() => {
ctx.onImageLoad(item.item_id); ctx.onImageLoad(item.item_id);

View File

@@ -1,32 +0,0 @@
import type { TextProps } from '@invoke-ai/ui-library';
import { Text } from '@invoke-ai/ui-library';
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
import { DROP_SHADOW, getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared';
import { memo } from 'react';
import type { S } from 'services/api/types';
type Props = { itemId: number; status: S['SessionQueueItem']['status'] } & TextProps;
export const QueueItemProgressMessage = memo(({ itemId, status, ...rest }: Props) => {
const ctx = useCanvasSessionContext();
const { progressEvent } = useProgressData(ctx.$progressData, itemId);
if (status === 'completed' || status === 'failed' || status === 'canceled') {
return null;
}
if (status === 'pending') {
return (
<Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>
Waiting to start...
</Text>
);
}
return (
<Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>
{getProgressMessage(progressEvent)}
</Text>
);
});
QueueItemProgressMessage.displayName = 'QueueItemProgressMessage';

View File

@@ -16,21 +16,21 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
if (item.status === 'pending') { if (item.status === 'pending') {
return ( return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}> <Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}>
Pending Pending
</Text> </Text>
); );
} }
if (item.status === 'canceled') { if (item.status === 'canceled') {
return ( return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}> <Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}>
Canceled Canceled
</Text> </Text>
); );
} }
if (item.status === 'failed') { if (item.status === 'failed') {
return ( return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}> <Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}>
Failed Failed
</Text> </Text>
); );
@@ -38,7 +38,7 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
if (item.status === 'in_progress') { if (item.status === 'in_progress') {
return ( return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}> <Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}>
In Progress In Progress
</Text> </Text>
); );
@@ -46,7 +46,14 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
if (item.status === 'completed') { if (item.status === 'completed') {
return ( return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeGreen.300" {...rest}> <Text
fontSize="xs"
pointerEvents="none"
userSelect="none"
fontWeight="semibold"
color="invokeGreen.300"
{...rest}
>
Completed Completed
</Text> </Text>
); );

View File

@@ -1,15 +0,0 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { StagingAreaHeader } from 'features/controlLayers/components/SimpleSession/StagingAreaHeader';
import { StagingAreaNoItems } from 'features/controlLayers/components/SimpleSession/StagingAreaNoItems';
import { memo } from 'react';
export const SimpleSessionNoId = memo(() => {
return (
<Flex flexDir="column" gap={2} w="full" h="full" minW={0} minH={0}>
<StagingAreaHeader />
<Divider />
<StagingAreaNoItems />
</Flex>
);
});
SimpleSessionNoId.displayName = 'StSimpleSessionNoIdagingArea';

View File

@@ -1,33 +0,0 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import { StagingAreaContent } from 'features/controlLayers/components/SimpleSession/StagingAreaContent';
import { StagingAreaHeader } from 'features/controlLayers/components/SimpleSession/StagingAreaHeader';
import { StagingAreaNoItems } from 'features/controlLayers/components/SimpleSession/StagingAreaNoItems';
import { useStagingAreaKeyboardNav } from 'features/controlLayers/components/SimpleSession/use-staging-keyboard-nav';
import { memo, useEffect } from 'react';
export const StagingArea = memo(() => {
const ctx = useCanvasSessionContext();
const hasItems = useStore(ctx.$hasItems);
useStagingAreaKeyboardNav();
useEffect(() => {
return ctx.$selectedItemId.listen((id) => {
if (id !== null) {
document.getElementById(getQueueItemElementId(id))?.scrollIntoView();
}
});
}, [ctx.$selectedItemId]);
return (
<Flex flexDir="column" gap={2} w="full" h="full" minW={0} minH={0}>
<StagingAreaHeader />
<Divider />
{hasItems && <StagingAreaContent />}
{!hasItems && <StagingAreaNoItems />}
</Flex>
);
});
StagingArea.displayName = 'StagingArea';

View File

@@ -1,23 +0,0 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
import { StagingAreaSelectedItem } from 'features/controlLayers/components/SimpleSession/StagingAreaSelectedItem';
import { SimpleStagingAreaToolbar } from 'features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar';
import { memo } from 'react';
export const StagingAreaContent = memo(() => {
return (
<>
<Flex position="relative" w="full" h="full" maxH="full" alignItems="center" justifyContent="center" minH={0}>
<StagingAreaSelectedItem />
</Flex>
<Divider />
<Flex position="relative" maxW="full" w="full" h={108} flexShrink={0}>
<StagingAreaItemsList />
</Flex>
<Flex gap={2} w="full" justifyContent="safe center">
<SimpleStagingAreaToolbar />
</Flex>
</>
);
});
StagingAreaContent.displayName = 'StagingAreaContent';

View File

@@ -1,12 +0,0 @@
import { Flex, Heading, Spacer } from '@invoke-ai/ui-library';
import { memo } from 'react';
export const StagingAreaHeader = memo(() => {
return (
<Flex gap={2} w="full" alignItems="center" px={2}>
<Heading size="sm">Review Session</Heading>
<Spacer />
</Flex>
);
});
StagingAreaHeader.displayName = 'StagingAreaHeader';

View File

@@ -21,18 +21,20 @@ export const StagingAreaItemsList = memo(() => {
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]); }, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]);
return ( return (
<ScrollableContent overflowX="scroll" overflowY="hidden"> <Flex position="relative" maxW="full" w="full" h="72px">
<Flex gap={2} w="full" h="full" justifyContent="safe center"> <ScrollableContent overflowX="scroll" overflowY="hidden">
{items.map((item, i) => ( <Flex gap={2} w="full" h="full" justifyContent="safe center">
<QueueItemPreviewMini {items.map((item, i) => (
key={`${item.item_id}-mini`} <QueueItemPreviewMini
item={item} key={`${item.item_id}-mini`}
number={i + 1} item={item}
isSelected={selectedItemId === item.item_id} number={i + 1}
/> isSelected={selectedItemId === item.item_id}
))} />
</Flex> ))}
</ScrollableContent> </Flex>
</ScrollableContent>
</Flex>
); );
}); });
StagingAreaItemsList.displayName = 'StagingAreaItemsList'; StagingAreaItemsList.displayName = 'StagingAreaItemsList';

View File

@@ -1,11 +0,0 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { memo } from 'react';
export const StagingAreaNoItems = memo(() => {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text>No generations</Text>
</Flex>
);
});
StagingAreaNoItems.displayName = 'StagingAreaNoItems';

View File

@@ -1,20 +0,0 @@
import { Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { QueueItemPreviewFull } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewFull';
import { memo } from 'react';
export const StagingAreaSelectedItem = memo(() => {
const ctx = useCanvasSessionContext();
const selectedItem = useStore(ctx.$selectedItem);
const selectedItemIndex = useStore(ctx.$selectedItemIndex);
if (selectedItem && selectedItemIndex !== null) {
return (
<QueueItemPreviewFull key={`${selectedItem.item_id}-full`} item={selectedItem} number={selectedItemIndex + 1} />
);
}
return <Text>No generation selected</Text>;
});
StagingAreaSelectedItem.displayName = 'StagingAreaSelectedItem';

View File

@@ -24,6 +24,7 @@ import {
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
import { LaunchpadButton } from './LaunchpadButton'; import { LaunchpadButton } from './LaunchpadButton';
import { LaunchpadContainer } from './LaunchpadContainer';
export const UpscalingLaunchpadPanel = memo(() => { export const UpscalingLaunchpadPanel = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -65,108 +66,104 @@ export const UpscalingLaunchpadPanel = memo(() => {
}, [dispatch]); }, [dispatch]);
return ( return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}> <LaunchpadContainer heading={t('ui.launchpad.upscalingTitle')}>
<Flex flexDir="column" w="full" gap={8} px={14} maxW={768} pt="20vh"> {/* Upload Area */}
<Heading>{t('ui.launchpad.upscalingTitle')}</Heading> <LaunchpadButton {...uploadApi.getUploadButtonProps()} position="relative" gap={8}>
{!upscaleInitialImage ? (
{/* Upload Area */} <>
<LaunchpadButton {...uploadApi.getUploadButtonProps()} position="relative" gap={8}> <Icon as={PiImageBold} boxSize={8} color="base.500" />
{!upscaleInitialImage ? ( <Flex flexDir="column" alignItems="flex-start" gap={2}>
<> <Heading size="sm">{t('ui.launchpad.upscaling.uploadImage.title')}</Heading>
<Icon as={PiImageBold} boxSize={8} color="base.500" /> <Text color="base.300">{t('ui.launchpad.upscaling.uploadImage.description')}</Text>
<Flex flexDir="column" alignItems="flex-start" gap={2}> </Flex>
<Heading size="sm">{t('ui.launchpad.upscaling.uploadImage.title')}</Heading> <Flex position="absolute" right={3} bottom={3}>
<Text color="base.300">{t('ui.launchpad.upscaling.uploadImage.description')}</Text> <PiUploadBold />
</Flex> <input {...uploadApi.getUploadInputProps()} />
<Flex position="absolute" right={3} bottom={3}> </Flex>
<PiUploadBold /> </>
<input {...uploadApi.getUploadInputProps()} /> ) : (
</Flex> <>
</> <Icon as={PiImageBold} boxSize={8} color="base.500" />
) : ( <Flex flexDir="column" alignItems="flex-start" gap={2}>
<> <Heading size="sm">{t('ui.launchpad.upscaling.replaceImage.title')}</Heading>
<Icon as={PiImageBold} boxSize={8} color="base.500" /> <Text color="base.300">{t('ui.launchpad.upscaling.replaceImage.description')}</Text>
<Flex flexDir="column" alignItems="flex-start" gap={2}> </Flex>
<Heading size="sm">{t('ui.launchpad.upscaling.replaceImage.title')}</Heading> <Flex position="absolute" right={3} bottom={3}>
<Text color="base.300">{t('ui.launchpad.upscaling.replaceImage.description')}</Text> <PiUploadBold />
</Flex> <input {...uploadApi.getUploadInputProps()} />
<Flex position="absolute" right={3} bottom={3}> </Flex>
<PiUploadBold /> </>
<input {...uploadApi.getUploadInputProps()} />
</Flex>
</>
)}
<DndDropTarget
dndTarget={setUpscaleInitialImageDndTarget}
dndTargetData={dndTargetData}
label={t('gallery.drop')}
/>
</LaunchpadButton>
{/* Guidance text */}
{upscaleInitialImage && (
<Flex bg="base.800" p={4} borderRadius="base" border="1px solid" borderColor="base.700">
<Text variant="subtext" fontSize="sm" lineHeight="1.6">
<strong>{t('ui.launchpad.upscaling.readyToUpscale.title')}</strong>{' '}
{t('ui.launchpad.upscaling.readyToUpscale.description')}
</Text>
</Flex>
)} )}
<DndDropTarget
dndTarget={setUpscaleInitialImageDndTarget}
dndTargetData={dndTargetData}
label={t('gallery.drop')}
/>
</LaunchpadButton>
{/* Controls */} {/* Guidance text */}
<Grid gridTemplateColumns="1fr 1fr" gap={8} alignItems="start"> {upscaleInitialImage && (
{/* Left Column: Creativity and Structural Defaults */} <Flex bg="base.800" p={4} borderRadius="base" border="1px solid" borderColor="base.700">
<Box> <Text variant="subtext" fontSize="sm" lineHeight="1.6">
<Text fontWeight="semibold" fontSize="sm" mb={3}> <strong>{t('ui.launchpad.upscaling.readyToUpscale.title')}</strong>{' '}
Creativity & Structure Defaults {t('ui.launchpad.upscaling.readyToUpscale.description')}
</Text> </Text>
<ButtonGroup size="sm" orientation="vertical" variant="outline" w="full"> </Flex>
<Button )}
colorScheme={creativity === -5 && structure === 5 ? 'invokeBlue' : undefined}
justifyContent="center" {/* Controls */}
onClick={onConservativeClick} <Grid gridTemplateColumns="1fr 1fr" gap={8} alignItems="start">
leftIcon={<PiShieldCheckBold />} {/* Left Column: Creativity and Structural Defaults */}
> <Box>
Conservative <Text fontWeight="semibold" fontSize="sm" mb={3}>
</Button> Creativity & Structure Defaults
<Button </Text>
colorScheme={creativity === 0 && structure === 0 ? 'invokeBlue' : undefined} <ButtonGroup size="sm" orientation="vertical" variant="outline" w="full">
justifyContent="center" <Button
onClick={onBalancedClick} colorScheme={creativity === -5 && structure === 5 ? 'invokeBlue' : undefined}
leftIcon={<PiScalesBold />} justifyContent="center"
> onClick={onConservativeClick}
Balanced leftIcon={<PiShieldCheckBold />}
</Button> >
<Button Conservative
colorScheme={creativity === 5 && structure === -2 ? 'invokeBlue' : undefined} </Button>
justifyContent="center" <Button
onClick={onCreativeClick} colorScheme={creativity === 0 && structure === 0 ? 'invokeBlue' : undefined}
leftIcon={<PiPaletteBold />} justifyContent="center"
> onClick={onBalancedClick}
Creative leftIcon={<PiScalesBold />}
</Button> >
<Button Balanced
colorScheme={creativity === 8 && structure === -5 ? 'invokeBlue' : undefined} </Button>
justifyContent="center" <Button
onClick={onArtisticClick} colorScheme={creativity === 5 && structure === -2 ? 'invokeBlue' : undefined}
leftIcon={<PiSparkleBold />} justifyContent="center"
> onClick={onCreativeClick}
Artistic leftIcon={<PiPaletteBold />}
</Button> >
</ButtonGroup> Creative
</Box> </Button>
{/* Right Column: Description/help text */} <Button
<Box> colorScheme={creativity === 8 && structure === -5 ? 'invokeBlue' : undefined}
<Text variant="subtext" fontSize="sm" lineHeight="1.6"> justifyContent="center"
{t('ui.launchpad.upscaling.helpText.promptAdvice')} onClick={onArtisticClick}
</Text> leftIcon={<PiSparkleBold />}
<Text variant="subtext" fontSize="sm" lineHeight="1.6" mt={3}> >
{t('ui.launchpad.upscaling.helpText.styleAdvice')} Artistic
</Text> </Button>
</Box> </ButtonGroup>
</Grid> </Box>
</Flex> {/* Right Column: Description/help text */}
</Flex> <Box>
<Text variant="subtext" fontSize="sm" lineHeight="1.6">
{t('ui.launchpad.upscaling.helpText.promptAdvice')}
</Text>
<Text variant="subtext" fontSize="sm" lineHeight="1.6" mt={3}>
{t('ui.launchpad.upscaling.helpText.styleAdvice')}
</Text>
</Box>
</Grid>
</LaunchpadContainer>
); );
}); });

View File

@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { PiFilePlusBold, PiFolderOpenBold, PiUploadBold } from 'react-icons/pi'; import { PiFilePlusBold, PiFolderOpenBold, PiUploadBold } from 'react-icons/pi';
import { LaunchpadButton } from './LaunchpadButton'; import { LaunchpadButton } from './LaunchpadButton';
import { LaunchpadContainer } from './LaunchpadContainer';
export const WorkflowsLaunchpadPanel = memo(() => { export const WorkflowsLaunchpadPanel = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -45,63 +46,59 @@ export const WorkflowsLaunchpadPanel = memo(() => {
}); });
return ( return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}> <LaunchpadContainer heading={t('ui.launchpad.workflowsTitle')}>
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh"> {/* Description */}
<Heading>{t('ui.launchpad.workflowsTitle')}</Heading> <Text variant="subtext" fontSize="md" lineHeight="1.6">
{t('ui.launchpad.workflows.description')}
</Text>
{/* Description */} <Text>
<Text variant="subtext" fontSize="md" lineHeight="1.6"> <Button
{t('ui.launchpad.workflows.description')} as="a"
</Text> variant="link"
href="https://support.invoke.ai/support/solutions/articles/151000189610-getting-started-with-workflows-denoise-latents"
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t('ui.launchpad.workflows.learnMoreLink')}
</Button>
</Text>
<Text> {/* Action Buttons */}
<Button <Flex flexDir="column" gap={8}>
as="a" {/* Browse Workflow Templates */}
variant="link" <LaunchpadButton onClick={handleBrowseTemplates} position="relative" gap={8}>
href="https://support.invoke.ai/support/solutions/articles/151000189610-getting-started-with-workflows-denoise-latents" <Icon as={PiFolderOpenBold} boxSize={8} color="base.500" />
target="_blank" <Flex flexDir="column" alignItems="flex-start" gap={2}>
rel="noopener noreferrer" <Heading size="sm">{t('ui.launchpad.workflows.browseTemplates.title')}</Heading>
size="sm" <Text color="base.300">{t('ui.launchpad.workflows.browseTemplates.description')}</Text>
> </Flex>
{t('ui.launchpad.workflows.learnMoreLink')} </LaunchpadButton>
</Button>
</Text>
{/* Action Buttons */} {/* Create a new Workflow */}
<Flex flexDir="column" gap={8}> <LaunchpadButton onClick={handleCreateNew} position="relative" gap={8}>
{/* Browse Workflow Templates */} <Icon as={PiFilePlusBold} boxSize={8} color="base.500" />
<LaunchpadButton onClick={handleBrowseTemplates} position="relative" gap={8}> <Flex flexDir="column" alignItems="flex-start" gap={2}>
<Icon as={PiFolderOpenBold} boxSize={8} color="base.500" /> <Heading size="sm">{t('ui.launchpad.workflows.createNew.title')}</Heading>
<Flex flexDir="column" alignItems="flex-start" gap={2}> <Text color="base.300">{t('ui.launchpad.workflows.createNew.description')}</Text>
<Heading size="sm">{t('ui.launchpad.workflows.browseTemplates.title')}</Heading> </Flex>
<Text color="base.300">{t('ui.launchpad.workflows.browseTemplates.description')}</Text> </LaunchpadButton>
</Flex>
</LaunchpadButton>
{/* Create a new Workflow */} {/* Load workflow from existing image or file */}
<LaunchpadButton onClick={handleCreateNew} position="relative" gap={8}> <LaunchpadButton {...uploadApi.getRootProps()} position="relative" gap={8}>
<Icon as={PiFilePlusBold} boxSize={8} color="base.500" /> <Icon as={PiUploadBold} boxSize={8} color="base.500" />
<Flex flexDir="column" alignItems="flex-start" gap={2}> <Flex flexDir="column" alignItems="flex-start" gap={2}>
<Heading size="sm">{t('ui.launchpad.workflows.createNew.title')}</Heading> <Heading size="sm">{t('ui.launchpad.workflows.loadFromFile.title')}</Heading>
<Text color="base.300">{t('ui.launchpad.workflows.createNew.description')}</Text> <Text color="base.300">{t('ui.launchpad.workflows.loadFromFile.description')}</Text>
</Flex> </Flex>
</LaunchpadButton> <Flex position="absolute" right={3} bottom={3}>
<PiUploadBold />
{/* Load workflow from existing image or file */} <input {...uploadApi.getInputProps()} />
<LaunchpadButton {...uploadApi.getRootProps()} position="relative" gap={8}> </Flex>
<Icon as={PiUploadBold} boxSize={8} color="base.500" /> </LaunchpadButton>
<Flex flexDir="column" alignItems="flex-start" gap={2}>
<Heading size="sm">{t('ui.launchpad.workflows.loadFromFile.title')}</Heading>
<Text color="base.300">{t('ui.launchpad.workflows.loadFromFile.description')}</Text>
</Flex>
<Flex position="absolute" right={3} bottom={3}>
<PiUploadBold />
<input {...uploadApi.getInputProps()} />
</Flex>
</LaunchpadButton>
</Flex>
</Flex> </Flex>
</Flex> </LaunchpadContainer>
); );
}); });

View File

@@ -1,9 +1,12 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppStore } from 'app/store/storeHooks'; import { useAppStore } from 'app/store/storeHooks';
import { buildZodTypeGuard } from 'common/util/zodUtils';
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
import { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice';
import {
buildSelectSessionQueueItems,
canvasQueueItemDiscarded,
canvasSessionReset,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import type { ProgressImage } from 'features/nodes/types/common'; import type { ProgressImage } from 'features/nodes/types/common';
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores'; import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
import { atom, computed, effect, map, subscribeKeys } from 'nanostores'; import { atom, computed, effect, map, subscribeKeys } from 'nanostores';
@@ -14,11 +17,6 @@ import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO, S } from 'services/api/types'; import type { ImageDTO, S } from 'services/api/types';
import { $socket } from 'services/events/stores'; import { $socket } from 'services/events/stores';
import { assert, objectEntries } from 'tsafe'; import { assert, objectEntries } from 'tsafe';
import { z } from 'zod/v4';
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
export const isAutoSwitchMode = buildZodTypeGuard(zAutoSwitchMode);
export type AutoSwitchMode = z.infer<typeof zAutoSwitchMode>;
export type ProgressData = { export type ProgressData = {
itemId: number; itemId: number;
@@ -98,12 +96,13 @@ type CanvasSessionContextValue = {
$selectedItem: Atom<S['SessionQueueItem'] | null>; $selectedItem: Atom<S['SessionQueueItem'] | null>;
$selectedItemIndex: Atom<number | null>; $selectedItemIndex: Atom<number | null>;
$selectedItemOutputImageDTO: Atom<ImageDTO | null>; $selectedItemOutputImageDTO: Atom<ImageDTO | null>;
$autoSwitch: WritableAtom<AutoSwitchMode>;
selectNext: () => void; selectNext: () => void;
selectPrev: () => void; selectPrev: () => void;
selectFirst: () => void; selectFirst: () => void;
selectLast: () => void; selectLast: () => void;
onImageLoad: (itemId: number) => void; onImageLoad: (itemId: number) => void;
discard: (itemId: number) => void;
discardAll: () => void;
}; };
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null); const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
@@ -140,11 +139,6 @@ export const CanvasSessionContextProvider = memo(
*/ */
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0]; const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
/**
* Whether auto-switch is enabled.
*/
const $autoSwitch = useState(() => atom<AutoSwitchMode>('switch_on_start'))[0];
/** /**
* An internal flag used to work around race conditions with auto-switch switching to queue items before their * An internal flag used to work around race conditions with auto-switch switching to queue items before their
* output images have fully loaded. * output images have fully loaded.
@@ -226,19 +220,21 @@ export const CanvasSessionContextProvider = memo(
)[0]; )[0];
/** /**
* A redux selector to select all queue items from the RTK Query cache. It's important that this returns stable * A redux selector to select all queue items from the RTK Query cache.
* references if possible to reduce re-renders. All derivations of the queue items (e.g. filtering out canceled
* items) should be done in a nanostores computed.
*/ */
const selectQueueItems = useMemo( const selectQueueItems = useMemo(() => buildSelectSessionQueueItems(session.id), [session.id]);
() =>
createSelector( const discard = useCallback(
queueApi.endpoints.listAllQueueItems.select({ destination: session.id }), (itemId: number) => {
({ data }) => data ?? EMPTY_ARRAY store.dispatch(canvasQueueItemDiscarded({ itemId }));
), },
[session.id] [store]
); );
const discardAll = useCallback(() => {
store.dispatch(canvasSessionReset());
}, [store]);
const selectNext = useCallback(() => { const selectNext = useCallback(() => {
const selectedItemId = $selectedItemId.get(); const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) { if (selectedItemId === null) {
@@ -300,12 +296,15 @@ export const CanvasSessionContextProvider = memo(
imageLoaded: true, imageLoaded: true,
}); });
} }
if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') { if (
$lastCompletedItemId.get() === itemId &&
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish'
) {
$selectedItemId.set(itemId); $selectedItemId.set(itemId);
$lastCompletedItemId.set(null); $lastCompletedItemId.set(null);
} }
}, },
[$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId] [$lastCompletedItemId, $progressData, $selectedItemId, store]
); );
// Set up socket listeners // Set up socket listeners
@@ -340,7 +339,7 @@ export const CanvasSessionContextProvider = memo(
socket.off('invocation_progress', onProgress); socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged); socket.off('queue_item_status_changed', onQueueItemStatusChanged);
}; };
}, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]); }, [$lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
// Set up state subscriptions and effects // Set up state subscriptions and effects
useEffect(() => { useEffect(() => {
@@ -362,33 +361,32 @@ export const CanvasSessionContextProvider = memo(
const unsubEnsureSelectedItemIdExists = effect( const unsubEnsureSelectedItemIdExists = effect(
[$items, $selectedItemId, $lastStartedItemId], [$items, $selectedItemId, $lastStartedItemId],
(items, selectedItemId, lastStartedItemId) => { (items, selectedItemId, lastStartedItemId) => {
// If there are no items, cannot have a selected item.
if (items.length === 0) { if (items.length === 0) {
// If there are no items, cannot have a selected item.
$selectedItemId.set(null); $selectedItemId.set(null);
return; } else if (selectedItemId === null && items.length > 0) {
} // If there is no selected item but there are items, select the first one.
// If there is no selected item but there are items, select the first one.
if (selectedItemId === null && items.length > 0) {
$selectedItemId.set(items[0]?.item_id ?? null); $selectedItemId.set(items[0]?.item_id ?? null);
return; return;
} } else if (
if ( selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' &&
$autoSwitch.get() === 'switch_on_start' &&
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1 items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
) { ) {
$selectedItemId.set(lastStartedItemId); $selectedItemId.set(lastStartedItemId);
$lastStartedItemId.set(null); $lastStartedItemId.set(null);
} } else if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll // If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
// the above case, selecting the first item if there are any. // the above case, selecting the first item if there are any.
if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId); let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
if (prevIndex >= items.length) { if (prevIndex >= items.length) {
prevIndex = items.length - 1; prevIndex = items.length - 1;
} }
const nextItem = items[prevIndex]; const nextItem = items[prevIndex];
$selectedItemId.set(nextItem?.item_id ?? null); $selectedItemId.set(nextItem?.item_id ?? null);
return; }
if (items !== _prevItems) {
_prevItems = items;
} }
} }
); );
@@ -409,12 +407,12 @@ export const CanvasSessionContextProvider = memo(
if (!item) { if (!item) {
toDelete.push(datum.itemId); toDelete.push(datum.itemId);
} else if (item.status === 'canceled' || item.status === 'failed') { } else if (item.status === 'canceled' || item.status === 'failed') {
toUpdate[datum.itemId] = { toUpdate.push({
...datum, ...datum,
progressEvent: null, progressEvent: null,
progressImage: null, progressImage: null,
imageDTO: null, imageDTO: null,
}; });
} }
} }
@@ -474,7 +472,7 @@ export const CanvasSessionContextProvider = memo(
if (lastLoadedItemId === null) { if (lastLoadedItemId === null) {
return; return;
} }
if ($autoSwitch.get() === 'switch_on_finish') { if (selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish') {
$selectedItemId.set(lastLoadedItemId); $selectedItemId.set(lastLoadedItemId);
} }
$lastLoadedItemId.set(null); $lastLoadedItemId.set(null);
@@ -486,6 +484,22 @@ export const CanvasSessionContextProvider = memo(
queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id }) queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id })
); );
// const unsubListener = store.dispatch(
// addAppListener({
// matcher: queueApi.endpoints.cancelQueueItem.matchFulfilled,
// effect: ({ payload }, { getState }) => {
// const { item_id } = payload;
// const items = selectQueueItems(getState());
// if (items.length === 0) {
// $selectedItemId.set(null);
// } else if ($selectedItemId.get() === null) {
// $selectedItemId.set(items[0].item_id);
// }
// },
// })
// );
// Clean up all subscriptions and top-level (i.e. non-computed/derived state) // Clean up all subscriptions and top-level (i.e. non-computed/derived state)
return () => { return () => {
unsubHandleAutoSwitch(); unsubHandleAutoSwitch();
@@ -498,7 +512,6 @@ export const CanvasSessionContextProvider = memo(
$selectedItemId.set(null); $selectedItemId.set(null);
}; };
}, [ }, [
$autoSwitch,
$items, $items,
$lastLoadedItemId, $lastLoadedItemId,
$lastStartedItemId, $lastStartedItemId,
@@ -517,7 +530,6 @@ export const CanvasSessionContextProvider = memo(
$isPending, $isPending,
$progressData, $progressData,
$selectedItemId, $selectedItemId,
$autoSwitch,
$selectedItem, $selectedItem,
$selectedItemIndex, $selectedItemIndex,
$selectedItemOutputImageDTO, $selectedItemOutputImageDTO,
@@ -527,9 +539,10 @@ export const CanvasSessionContextProvider = memo(
selectFirst, selectFirst,
selectLast, selectLast,
onImageLoad, onImageLoad,
discard,
discardAll,
}), }),
[ [
$autoSwitch,
$items, $items,
$hasItems, $hasItems,
$isPending, $isPending,
@@ -545,6 +558,8 @@ export const CanvasSessionContextProvider = memo(
selectFirst, selectFirst,
selectLast, selectLast,
onImageLoad, onImageLoad,
discard,
discardAll,
] ]
); );

View File

@@ -1,11 +0,0 @@
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useHotkeys } from 'react-hotkeys-hook';
export const useStagingAreaKeyboardNav = () => {
const ctx = useCanvasSessionContext();
useHotkeys('left', ctx.selectPrev, { preventDefault: true });
useHotkeys('right', ctx.selectNext, { preventDefault: true });
useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });
};

View File

@@ -1,27 +0,0 @@
import { ButtonGroup } from '@invoke-ai/ui-library';
import { SimpleStagingAreaToolbarMenu } from 'features/controlLayers/components/StagingArea/SimpleStagingAreaToolbarMenu';
import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton';
import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton';
import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton';
import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton';
import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton';
import { memo } from 'react';
export const SimpleStagingAreaToolbar = memo(() => {
return (
<>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarPrevButton />
<StagingAreaToolbarImageCountButton />
<StagingAreaToolbarNextButton />
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarDiscardSelectedButton />
<SimpleStagingAreaToolbarMenu />
<StagingAreaToolbarDiscardAllButton />
</ButtonGroup>
</>
);
});
SimpleStagingAreaToolbar.displayName = 'SimpleStagingAreaToolbar';

View File

@@ -1,17 +0,0 @@
import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { StagingAreaToolbarMenuAutoSwitch } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch';
import { memo } from 'react';
import { PiDotsThreeBold } from 'react-icons/pi';
export const SimpleStagingAreaToolbarMenu = memo(() => {
return (
<Menu>
<MenuButton as={IconButton} icon={<PiDotsThreeBold />} colorScheme="invokeBlue" />
<MenuList>
<StagingAreaToolbarMenuAutoSwitch />
</MenuList>
</Menu>
);
});
SimpleStagingAreaToolbarMenu.displayName = 'SimpleStagingAreaToolbarMenu';

View File

@@ -0,0 +1,50 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectStagingAreaAutoSwitch,
settingsStagingAreaAutoSwitchChanged,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { PiCaretLineRightBold, PiCaretRightBold, PiMoonBold } from 'react-icons/pi';
export const StagingAreaAutoSwitchButtons = memo(() => {
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
const dispatch = useAppDispatch();
const onClickOff = useCallback(() => {
dispatch(settingsStagingAreaAutoSwitchChanged('off'));
}, [dispatch]);
const onClickSwitchOnStart = useCallback(() => {
dispatch(settingsStagingAreaAutoSwitchChanged('switch_on_start'));
}, [dispatch]);
const onClickSwitchOnFinished = useCallback(() => {
dispatch(settingsStagingAreaAutoSwitchChanged('switch_on_finish'));
}, [dispatch]);
return (
<>
<IconButton
aria-label="Do not auto-switch"
tooltip="Do not auto-switch"
icon={<PiMoonBold />}
colorScheme={autoSwitch === 'off' ? 'invokeBlue' : 'base'}
onClick={onClickOff}
/>
<IconButton
aria-label="Switch on start"
tooltip="Switch on start"
icon={<PiCaretRightBold />}
colorScheme={autoSwitch === 'switch_on_start' ? 'invokeBlue' : 'base'}
onClick={onClickSwitchOnStart}
/>
<IconButton
aria-label="Switch on finish"
tooltip="Switch on finish"
icon={<PiCaretLineRightBold />}
colorScheme={autoSwitch === 'switch_on_finish' ? 'invokeBlue' : 'base'}
onClick={onClickSwitchOnFinished}
/>
</>
);
});
StagingAreaAutoSwitchButtons.displayName = 'StagingAreaAutoSwitchButtons';

View File

@@ -1,4 +1,4 @@
import { ButtonGroup } from '@invoke-ai/ui-library'; import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
@@ -15,6 +15,8 @@ import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerP
import { memo, useEffect } from 'react'; import { memo, useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { StagingAreaAutoSwitchButtons } from './StagingAreaAutoSwitchButtons';
export const StagingAreaToolbar = memo(() => { export const StagingAreaToolbar = memo(() => {
const canvasManager = useCanvasManager(); const canvasManager = useCanvasManager();
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
@@ -24,15 +26,18 @@ export const StagingAreaToolbar = memo(() => {
useEffect(() => { useEffect(() => {
return ctx.$selectedItemId.listen((id) => { return ctx.$selectedItemId.listen((id) => {
if (id !== null) { if (id !== null) {
document.getElementById(getQueueItemElementId(id))?.scrollIntoView(); document
.getElementById(getQueueItemElementId(id))
?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
} }
}); });
}, [ctx.$selectedItemId]); }, [ctx.$selectedItemId]);
useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true }); useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true }); useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });
return ( return (
<> <Flex gap={2}>
<ButtonGroup borderRadius="base" shadow="dark-lg"> <ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarPrevButton isDisabled={!shouldShowStagedImage} /> <StagingAreaToolbarPrevButton isDisabled={!shouldShowStagedImage} />
<StagingAreaToolbarImageCountButton /> <StagingAreaToolbarImageCountButton />
@@ -44,9 +49,14 @@ export const StagingAreaToolbar = memo(() => {
<StagingAreaToolbarSaveSelectedToGalleryButton /> <StagingAreaToolbarSaveSelectedToGalleryButton />
<StagingAreaToolbarMenu /> <StagingAreaToolbarMenu />
<StagingAreaToolbarDiscardSelectedButton isDisabled={!shouldShowStagedImage} /> <StagingAreaToolbarDiscardSelectedButton isDisabled={!shouldShowStagedImage} />
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaAutoSwitchButtons />
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarDiscardAllButton isDisabled={!shouldShowStagedImage} /> <StagingAreaToolbarDiscardAllButton isDisabled={!shouldShowStagedImage} />
</ButtonGroup> </ButtonGroup>
</> </Flex>
); );
}); });

View File

@@ -9,7 +9,7 @@ import { canvasSessionReset } from 'features/controlLayers/store/canvasStagingAr
import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
import { imageNameToImageObject } from 'features/controlLayers/store/util'; import { imageNameToImageObject } from 'features/controlLayers/store/util';
import { useDeleteQueueItemsByDestination } from 'features/queue/hooks/useDeleteQueueItemsByDestination'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -24,7 +24,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasFocused = useIsRegionFocused('canvas'); const isCanvasFocused = useIsRegionFocused('canvas');
const selectedItemImageDTO = useStore(ctx.$selectedItemOutputImageDTO); const selectedItemImageDTO = useStore(ctx.$selectedItemOutputImageDTO);
const deleteQueueItemsByDestination = useDeleteQueueItemsByDestination(); const cancelQueueItemsByDestination = useCancelQueueItemsByDestination();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -41,13 +41,13 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' })); dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' }));
dispatch(canvasSessionReset()); dispatch(canvasSessionReset());
deleteQueueItemsByDestination.trigger(ctx.session.id); cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
}, [ }, [
selectedItemImageDTO, selectedItemImageDTO,
bboxRect, bboxRect,
dispatch, dispatch,
selectedEntityIdentifier?.type, selectedEntityIdentifier?.type,
deleteQueueItemsByDestination, cancelQueueItemsByDestination,
ctx.session.id, ctx.session.id,
]); ]);
@@ -68,8 +68,8 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
icon={<PiCheckBold />} icon={<PiCheckBold />}
onClick={acceptSelected} onClick={acceptSelected}
colorScheme="invokeBlue" colorScheme="invokeBlue"
isDisabled={!selectedItemImageDTO || !shouldShowStagedImage || deleteQueueItemsByDestination.isDisabled} isDisabled={!selectedItemImageDTO || !shouldShowStagedImage || cancelQueueItemsByDestination.isDisabled}
isLoading={deleteQueueItemsByDestination.isLoading} isLoading={cancelQueueItemsByDestination.isLoading}
/> />
); );
}); });

View File

@@ -1,27 +1,19 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination';
import { useDeleteQueueItemsByDestination } from 'features/queue/hooks/useDeleteQueueItemsByDestination';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi'; import { PiTrashSimpleBold } from 'react-icons/pi';
export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
const ctx = useCanvasSessionContext(); const ctx = useCanvasSessionContext();
const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const deleteQueueItemsByDestination = useDeleteQueueItemsByDestination(); const cancelQueueItemsByDestination = useCancelQueueItemsByDestination();
const discardAll = useCallback(() => { const discardAll = useCallback(() => {
deleteQueueItemsByDestination.trigger(ctx.session.id); ctx.discardAll();
if (ctx.session.type === 'advanced') { cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
dispatch(canvasSessionReset()); }, [cancelQueueItemsByDestination, ctx]);
} else {
// ctx.session.type === 'simple'
dispatch(generateSessionReset());
}
}, [deleteQueueItemsByDestination, ctx.session.id, ctx.session.type, dispatch]);
return ( return (
<IconButton <IconButton
@@ -30,9 +22,8 @@ export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisa
icon={<PiTrashSimpleBold />} icon={<PiTrashSimpleBold />}
onClick={discardAll} onClick={discardAll}
colorScheme="error" colorScheme="error"
fontSize={16} isDisabled={isDisabled || cancelQueueItemsByDestination.isDisabled}
isDisabled={isDisabled || deleteQueueItemsByDestination.isDisabled} isLoading={cancelQueueItemsByDestination.isLoading}
isLoading={deleteQueueItemsByDestination.isLoading}
/> />
); );
}); });

View File

@@ -1,17 +1,14 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi'; import { PiXBold } from 'react-icons/pi';
export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
const dispatch = useAppDispatch();
const ctx = useCanvasSessionContext(); const ctx = useCanvasSessionContext();
const deleteQueueItem = useDeleteQueueItem(); const cancelQueueItem = useCancelQueueItem();
const selectedItemId = useStore(ctx.$selectedItemId); const selectedItemId = useStore(ctx.$selectedItemId);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -20,17 +17,9 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i
if (selectedItemId === null) { if (selectedItemId === null) {
return; return;
} }
await deleteQueueItem.trigger(selectedItemId); ctx.discard(selectedItemId);
const itemCount = ctx.$itemCount.get(); await cancelQueueItem.trigger(selectedItemId, { withToast: false });
if (itemCount <= 1) { }, [selectedItemId, ctx, cancelQueueItem]);
if (ctx.session.type === 'advanced') {
dispatch(canvasSessionReset());
} else {
// ctx.session.type === 'simple'
dispatch(generateSessionReset());
}
}
}, [selectedItemId, deleteQueueItem, ctx.$itemCount, ctx.session.type, dispatch]);
return ( return (
<IconButton <IconButton
@@ -39,9 +28,8 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i
icon={<PiXBold />} icon={<PiXBold />}
onClick={discardSelected} onClick={discardSelected}
colorScheme="invokeBlue" colorScheme="invokeBlue"
fontSize={16} isDisabled={selectedItemId === null || cancelQueueItem.isDisabled || isDisabled}
isDisabled={selectedItemId === null || deleteQueueItem.isDisabled || isDisabled} isLoading={cancelQueueItem.isLoading}
isLoading={deleteQueueItem.isLoading}
/> />
); );
}); });

View File

@@ -1,16 +1,13 @@
import { IconButton, Menu, MenuButton, MenuDivider, MenuList } from '@invoke-ai/ui-library'; import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { StagingAreaToolbarMenuAutoSwitch } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch';
import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage'; import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage';
import { memo } from 'react'; import { memo } from 'react';
import { PiDotsThreeBold } from 'react-icons/pi'; import { PiDotsThreeVerticalBold } from 'react-icons/pi';
export const StagingAreaToolbarMenu = memo(() => { export const StagingAreaToolbarMenu = memo(() => {
return ( return (
<Menu> <Menu>
<MenuButton as={IconButton} icon={<PiDotsThreeBold />} colorScheme="invokeBlue" /> <MenuButton as={IconButton} icon={<PiDotsThreeVerticalBold />} colorScheme="invokeBlue" />
<MenuList> <MenuList>
<StagingAreaToolbarMenuAutoSwitch />
<MenuDivider />
<StagingAreaToolbarNewLayerFromImageMenuItems /> <StagingAreaToolbarNewLayerFromImageMenuItems />
</MenuList> </MenuList>
</Menu> </Menu>

View File

@@ -1,34 +0,0 @@
import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { isAutoSwitchMode, useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { memo, useCallback } from 'react';
import { assert } from 'tsafe';
export const StagingAreaToolbarMenuAutoSwitch = memo(() => {
const ctx = useCanvasSessionContext();
const autoSwitch = useStore(ctx.$autoSwitch);
const onChange = useCallback(
(val: string | string[]) => {
assert(isAutoSwitchMode(val));
ctx.$autoSwitch.set(val);
},
[ctx.$autoSwitch]
);
return (
<MenuOptionGroup value={autoSwitch} onChange={onChange} title="Auto-Switch" type="radio">
<MenuItemOption value="off" closeOnSelect={false}>
Off
</MenuItemOption>
<MenuItemOption value="switch_on_start" closeOnSelect={false}>
Switch on Start
</MenuItemOption>
<MenuItemOption value="switch_on_finish" closeOnSelect={false}>
Switch on Finish
</MenuItemOption>
</MenuOptionGroup>
);
});
StagingAreaToolbarMenuAutoSwitch.displayName = 'StagingAreaToolbarMenuAutoSwitch';

View File

@@ -16,7 +16,6 @@ import {
rgRefImageAdded, rgRefImageAdded,
} from 'features/controlLayers/store/canvasSlice'; } from 'features/controlLayers/store/canvasSlice';
import { selectBase, selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import { selectBase, selectMainModelConfig } from 'features/controlLayers/store/paramsSlice';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import type { import type {
CanvasEntityIdentifier, CanvasEntityIdentifier,
@@ -88,7 +87,7 @@ export const getDefaultRefImageConfig = (
return config; return config;
} }
if (base === 'flux-kontext') { if (base === 'flux-kontext' || (base === 'flux' && mainModelConfig?.name?.toLowerCase().includes('kontext'))) {
const config = deepClone(initialFluxKontextReferenceImage); const config = deepClone(initialFluxKontextReferenceImage);
config.model = zModelIdentifierField.parse(mainModelConfig); config.model = zModelIdentifierField.parse(mainModelConfig);
return config; return config;
@@ -186,17 +185,6 @@ export const useAddNewRegionalGuidanceWithARefImage = () => {
return func; return func;
}; };
export const useAddGlobalReferenceImage = () => {
const { dispatch, getState } = useAppStore();
const func = useCallback(() => {
const config = getDefaultRefImageConfig(getState);
const overrides = { config };
dispatch(refImageAdded({ overrides }));
}, [dispatch, getState]);
return func;
};
export const useAddRefImageToExistingRegionalGuidance = ( export const useAddRefImageToExistingRegionalGuidance = (
entityIdentifier: CanvasEntityIdentifier<'regional_guidance'> entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>
) => { ) => {

View File

@@ -1,38 +1,41 @@
import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import type { PayloadAction, Selector } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store'; import type { PersistConfig, RootState } from 'app/store/store';
import type { RgbaColor } from 'features/controlLayers/store/types'; import { zRgbaColor } from 'features/controlLayers/store/types';
import { z } from 'zod/v4';
type CanvasSettingsState = { const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
const zCanvasSettingsState = z.object({
/** /**
* Whether to show HUD (Heads-Up Display) on the canvas. * Whether to show HUD (Heads-Up Display) on the canvas.
*/ */
showHUD: boolean; showHUD: z.boolean().default(true),
/** /**
* Whether to clip lines and shapes to the generation bounding box. If disabled, lines and shapes will be clipped to * Whether to clip lines and shapes to the generation bounding box. If disabled, lines and shapes will be clipped to
* the canvas bounds. * the canvas bounds.
*/ */
clipToBbox: boolean; clipToBbox: z.boolean().default(false),
/** /**
* Whether to show a dynamic grid on the canvas. If disabled, a checkerboard pattern will be shown instead. * Whether to show a dynamic grid on the canvas. If disabled, a checkerboard pattern will be shown instead.
*/ */
dynamicGrid: boolean; dynamicGrid: z.boolean().default(false),
/** /**
* Whether to invert the scroll direction when adjusting the brush or eraser width with the scroll wheel. * Whether to invert the scroll direction when adjusting the brush or eraser width with the scroll wheel.
*/ */
invertScrollForToolWidth: boolean; invertScrollForToolWidth: z.boolean().default(false),
/** /**
* The width of the brush tool. * The width of the brush tool.
*/ */
brushWidth: number; brushWidth: z.int().gt(0).default(50),
/** /**
* The width of the eraser tool. * The width of the eraser tool.
*/ */
eraserWidth: number; eraserWidth: z.int().gt(0).default(50),
/** /**
* The color to use when drawing lines or filling shapes. * The color to use when drawing lines or filling shapes.
*/ */
color: RgbaColor; color: zRgbaColor.default({ r: 31, g: 160, b: 224, a: 1 }), // invokeBlue.500
/** /**
* Whether to composite inpainted/outpainted regions back onto the source image when saving canvas generations. * Whether to composite inpainted/outpainted regions back onto the source image when saving canvas generations.
* *
@@ -40,70 +43,61 @@ type CanvasSettingsState = {
* *
* When `sendToCanvas` is disabled, this setting is ignored, masked regions will always be composited. * When `sendToCanvas` is disabled, this setting is ignored, masked regions will always be composited.
*/ */
outputOnlyMaskedRegions: boolean; outputOnlyMaskedRegions: z.boolean().default(true),
/** /**
* Whether to automatically process the operations like filtering and auto-masking. * Whether to automatically process the operations like filtering and auto-masking.
*/ */
autoProcess: boolean; autoProcess: z.boolean().default(true),
/** /**
* The snap-to-grid setting for the canvas. * The snap-to-grid setting for the canvas.
*/ */
snapToGrid: boolean; snapToGrid: z.boolean().default(true),
/** /**
* Whether to show progress on the canvas when generating images. * Whether to show progress on the canvas when generating images.
*/ */
showProgressOnCanvas: boolean; showProgressOnCanvas: z.boolean().default(true),
/** /**
* Whether to show the bounding box overlay on the canvas. * Whether to show the bounding box overlay on the canvas.
*/ */
bboxOverlay: boolean; bboxOverlay: z.boolean().default(false),
/** /**
* Whether to preserve the masked region instead of inpainting it. * Whether to preserve the masked region instead of inpainting it.
*/ */
preserveMask: boolean; preserveMask: z.boolean().default(false),
/** /**
* Whether to show only raster layers while staging. * Whether to show only raster layers while staging.
*/ */
isolatedStagingPreview: boolean; isolatedStagingPreview: z.boolean().default(true),
/** /**
* Whether to show only the selected layer while filtering, transforming, or doing other operations. * Whether to show only the selected layer while filtering, transforming, or doing other operations.
*/ */
isolatedLayerPreview: boolean; isolatedLayerPreview: z.boolean().default(true),
/** /**
* Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used. * Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
*/ */
pressureSensitivity: boolean; pressureSensitivity: z.boolean().default(true),
/** /**
* Whether to show the rule of thirds composition guide overlay on the canvas. * Whether to show the rule of thirds composition guide overlay on the canvas.
*/ */
ruleOfThirds: boolean; ruleOfThirds: z.boolean().default(false),
}; /**
* Whether to save all staging images to the gallery instead of keeping them as intermediate images.
*/
saveAllImagesToGallery: z.boolean().default(false),
/**
* The auto-switch mode for the canvas staging area.
*/
stagingAreaAutoSwitch: zAutoSwitchMode.default('switch_on_start'),
});
const initialState: CanvasSettingsState = { type CanvasSettingsState = z.infer<typeof zCanvasSettingsState>;
showHUD: true, const getInitialState = () => zCanvasSettingsState.parse({});
clipToBbox: false,
dynamicGrid: false,
brushWidth: 50,
eraserWidth: 50,
invertScrollForToolWidth: false,
color: { r: 31, g: 160, b: 224, a: 1 }, // invokeBlue.500
outputOnlyMaskedRegions: true,
autoProcess: true,
snapToGrid: true,
showProgressOnCanvas: true,
bboxOverlay: false,
preserveMask: false,
isolatedStagingPreview: true,
isolatedLayerPreview: true,
pressureSensitivity: true,
ruleOfThirds: false,
};
export const canvasSettingsSlice = createSlice({ export const canvasSettingsSlice = createSlice({
name: 'canvasSettings', name: 'canvasSettings',
initialState, initialState: getInitialState(),
reducers: { reducers: {
settingsClipToBboxChanged: (state, action: PayloadAction<boolean>) => { settingsClipToBboxChanged: (state, action: PayloadAction<CanvasSettingsState['clipToBbox']>) => {
state.clipToBbox = action.payload; state.clipToBbox = action.payload;
}, },
settingsDynamicGridToggled: (state) => { settingsDynamicGridToggled: (state) => {
@@ -112,16 +106,19 @@ export const canvasSettingsSlice = createSlice({
settingsShowHUDToggled: (state) => { settingsShowHUDToggled: (state) => {
state.showHUD = !state.showHUD; state.showHUD = !state.showHUD;
}, },
settingsBrushWidthChanged: (state, action: PayloadAction<number>) => { settingsBrushWidthChanged: (state, action: PayloadAction<CanvasSettingsState['brushWidth']>) => {
state.brushWidth = Math.round(action.payload); state.brushWidth = Math.round(action.payload);
}, },
settingsEraserWidthChanged: (state, action: PayloadAction<number>) => { settingsEraserWidthChanged: (state, action: PayloadAction<CanvasSettingsState['eraserWidth']>) => {
state.eraserWidth = Math.round(action.payload); state.eraserWidth = Math.round(action.payload);
}, },
settingsColorChanged: (state, action: PayloadAction<RgbaColor>) => { settingsColorChanged: (state, action: PayloadAction<CanvasSettingsState['color']>) => {
state.color = action.payload; state.color = action.payload;
}, },
settingsInvertScrollForToolWidthChanged: (state, action: PayloadAction<boolean>) => { settingsInvertScrollForToolWidthChanged: (
state,
action: PayloadAction<CanvasSettingsState['invertScrollForToolWidth']>
) => {
state.invertScrollForToolWidth = action.payload; state.invertScrollForToolWidth = action.payload;
}, },
settingsOutputOnlyMaskedRegionsToggled: (state) => { settingsOutputOnlyMaskedRegionsToggled: (state) => {
@@ -154,6 +151,15 @@ export const canvasSettingsSlice = createSlice({
settingsRuleOfThirdsToggled: (state) => { settingsRuleOfThirdsToggled: (state) => {
state.ruleOfThirds = !state.ruleOfThirds; state.ruleOfThirds = !state.ruleOfThirds;
}, },
settingsSaveAllImagesToGalleryToggled: (state) => {
state.saveAllImagesToGallery = !state.saveAllImagesToGallery;
},
settingsStagingAreaAutoSwitchChanged: (
state,
action: PayloadAction<CanvasSettingsState['stagingAreaAutoSwitch']>
) => {
state.stagingAreaAutoSwitch = action.payload;
},
}, },
}); });
@@ -175,6 +181,8 @@ export const {
settingsIsolatedLayerPreviewToggled, settingsIsolatedLayerPreviewToggled,
settingsPressureSensitivityToggled, settingsPressureSensitivityToggled,
settingsRuleOfThirdsToggled, settingsRuleOfThirdsToggled,
settingsSaveAllImagesToGalleryToggled,
settingsStagingAreaAutoSwitchChanged,
} = canvasSettingsSlice.actions; } = canvasSettingsSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -184,7 +192,7 @@ const migrate = (state: any): any => {
export const canvasSettingsPersistConfig: PersistConfig<CanvasSettingsState> = { export const canvasSettingsPersistConfig: PersistConfig<CanvasSettingsState> = {
name: canvasSettingsSlice.name, name: canvasSettingsSlice.name,
initialState, initialState: getInitialState(),
migrate, migrate,
persistDenylist: [], persistDenylist: [],
}; };
@@ -209,3 +217,5 @@ export const selectIsolatedStagingPreview = createCanvasSettingsSelector((settin
export const selectIsolatedLayerPreview = createCanvasSettingsSelector((settings) => settings.isolatedLayerPreview); export const selectIsolatedLayerPreview = createCanvasSettingsSelector((settings) => settings.isolatedLayerPreview);
export const selectPressureSensitivity = createCanvasSettingsSelector((settings) => settings.pressureSensitivity); export const selectPressureSensitivity = createCanvasSettingsSelector((settings) => settings.pressureSensitivity);
export const selectRuleOfThirds = createCanvasSettingsSelector((settings) => settings.ruleOfThirds); export const selectRuleOfThirds = createCanvasSettingsSelector((settings) => settings.ruleOfThirds);
export const selectSaveAllImagesToGallery = createCanvasSettingsSelector((settings) => settings.saveAllImagesToGallery);
export const selectStagingAreaAutoSwitch = createCanvasSettingsSelector((settings) => settings.stagingAreaAutoSwitch);

View File

@@ -32,7 +32,6 @@ import {
} from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
import { simplifyFlatNumbersArray } from 'features/controlLayers/util/simplify'; import { simplifyFlatNumbersArray } from 'features/controlLayers/util/simplify';
import { isMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import { isMainModelBase, zModelIdentifierField } from 'features/nodes/types/common';
import { ASPECT_RATIO_MAP } from 'features/parameters/components/Bbox/constants';
import { API_BASE_MODELS } from 'features/parameters/types/constants'; import { API_BASE_MODELS } from 'features/parameters/types/constants';
import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
@@ -69,9 +68,13 @@ import type {
T2IAdapterConfig, T2IAdapterConfig,
} from './types'; } from './types';
import { import {
ASPECT_RATIO_MAP,
CHATGPT_ASPECT_RATIOS,
DEFAULT_ASPECT_RATIO_CONFIG, DEFAULT_ASPECT_RATIO_CONFIG,
FLUX_KONTEXT_ASPECT_RATIOS,
getEntityIdentifier, getEntityIdentifier,
getInitialCanvasState, getInitialCanvasState,
IMAGEN_ASPECT_RATIOS,
isChatGPT4oAspectRatioID, isChatGPT4oAspectRatioID,
isFluxKontextAspectRatioID, isFluxKontextAspectRatioID,
isFLUXReduxConfig, isFLUXReduxConfig,
@@ -1100,62 +1103,21 @@ export const canvasSlice = createSlice({
(state.bbox.modelBase === 'imagen3' || state.bbox.modelBase === 'imagen4') && (state.bbox.modelBase === 'imagen3' || state.bbox.modelBase === 'imagen4') &&
isImagenAspectRatioID(id) isImagenAspectRatioID(id)
) { ) {
// Imagen3 has specific output sizes that are not exactly the same as the aspect ratio. Need special handling. const { width, height } = IMAGEN_ASPECT_RATIOS[id];
if (id === '16:9') { state.bbox.rect.width = width;
state.bbox.rect.width = 1408; state.bbox.rect.height = height;
state.bbox.rect.height = 768;
} else if (id === '4:3') {
state.bbox.rect.width = 1280;
state.bbox.rect.height = 896;
} else if (id === '1:1') {
state.bbox.rect.width = 1024;
state.bbox.rect.height = 1024;
} else if (id === '3:4') {
state.bbox.rect.width = 896;
state.bbox.rect.height = 1280;
} else if (id === '9:16') {
state.bbox.rect.width = 768;
state.bbox.rect.height = 1408;
}
state.bbox.aspectRatio.value = state.bbox.rect.width / state.bbox.rect.height; state.bbox.aspectRatio.value = state.bbox.rect.width / state.bbox.rect.height;
state.bbox.aspectRatio.isLocked = true; state.bbox.aspectRatio.isLocked = true;
} else if (state.bbox.modelBase === 'chatgpt-4o' && isChatGPT4oAspectRatioID(id)) { } else if (state.bbox.modelBase === 'chatgpt-4o' && isChatGPT4oAspectRatioID(id)) {
// gpt-image has specific output sizes that are not exactly the same as the aspect ratio. Need special handling. const { width, height } = CHATGPT_ASPECT_RATIOS[id];
if (id === '3:2') { state.bbox.rect.width = width;
state.bbox.rect.width = 1536; state.bbox.rect.height = height;
state.bbox.rect.height = 1024;
} else if (id === '1:1') {
state.bbox.rect.width = 1024;
state.bbox.rect.height = 1024;
} else if (id === '2:3') {
state.bbox.rect.width = 1024;
state.bbox.rect.height = 1536;
}
state.bbox.aspectRatio.value = state.bbox.rect.width / state.bbox.rect.height; state.bbox.aspectRatio.value = state.bbox.rect.width / state.bbox.rect.height;
state.bbox.aspectRatio.isLocked = true; state.bbox.aspectRatio.isLocked = true;
} else if (state.bbox.modelBase === 'flux-kontext' && isFluxKontextAspectRatioID(id)) { } else if (state.bbox.modelBase === 'flux-kontext' && isFluxKontextAspectRatioID(id)) {
if (id === '3:4') { const { width, height } = FLUX_KONTEXT_ASPECT_RATIOS[id];
state.bbox.rect.width = 880; state.bbox.rect.width = width;
state.bbox.rect.height = 1184; state.bbox.rect.height = height;
} else if (id === '4:3') {
state.bbox.rect.width = 1184;
state.bbox.rect.height = 880;
} else if (id === '9:16') {
state.bbox.rect.width = 752;
state.bbox.rect.height = 1392;
} else if (id === '16:9') {
state.bbox.rect.width = 1392;
state.bbox.rect.height = 752;
} else if (id === '21:9') {
state.bbox.rect.width = 1568;
state.bbox.rect.height = 672;
} else if (id === '9:21') {
state.bbox.rect.width = 672;
state.bbox.rect.height = 1568;
} else if (id === '1:1') {
state.bbox.rect.width = 1024;
state.bbox.rect.height = 1024;
}
state.bbox.aspectRatio.value = state.bbox.rect.width / state.bbox.rect.height; state.bbox.aspectRatio.value = state.bbox.rect.width / state.bbox.rect.height;
state.bbox.aspectRatio.isLocked = true; state.bbox.aspectRatio.isLocked = true;
} else { } else {

View File

@@ -1,16 +1,20 @@
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import type { PersistConfig, RootState } from 'app/store/store'; import type { PersistConfig, RootState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone'; import { deepClone } from 'common/util/deepClone';
import { canvasReset } from 'features/controlLayers/store/actions'; import { canvasReset } from 'features/controlLayers/store/actions';
import { queueApi } from 'services/api/endpoints/queue';
type CanvasStagingAreaState = { type CanvasStagingAreaState = {
generateSessionId: string | null; generateSessionId: string | null;
canvasSessionId: string | null; canvasSessionId: string | null;
canvasDiscardedQueueItems: number[];
}; };
const INITIAL_STATE: CanvasStagingAreaState = { const INITIAL_STATE: CanvasStagingAreaState = {
generateSessionId: null, generateSessionId: null,
canvasSessionId: null, canvasSessionId: null,
canvasDiscardedQueueItems: [],
}; };
const getInitialState = (): CanvasStagingAreaState => deepClone(INITIAL_STATE); const getInitialState = (): CanvasStagingAreaState => deepClone(INITIAL_STATE);
@@ -26,12 +30,20 @@ export const canvasSessionSlice = createSlice({
generateSessionReset: (state) => { generateSessionReset: (state) => {
state.generateSessionId = null; state.generateSessionId = null;
}, },
canvasQueueItemDiscarded: (state, action: PayloadAction<{ itemId: number }>) => {
const { itemId } = action.payload;
if (!state.canvasDiscardedQueueItems.includes(itemId)) {
state.canvasDiscardedQueueItems.push(itemId);
}
},
canvasSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => { canvasSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => {
const { id } = action.payload; const { id } = action.payload;
state.canvasSessionId = id; state.canvasSessionId = id;
state.canvasDiscardedQueueItems = [];
}, },
canvasSessionReset: (state) => { canvasSessionReset: (state) => {
state.canvasSessionId = null; state.canvasSessionId = null;
state.canvasDiscardedQueueItems = [];
}, },
}, },
extraReducers(builder) { extraReducers(builder) {
@@ -41,8 +53,13 @@ export const canvasSessionSlice = createSlice({
}, },
}); });
export const { generateSessionIdChanged, generateSessionReset, canvasSessionIdChanged, canvasSessionReset } = export const {
canvasSessionSlice.actions; generateSessionIdChanged,
generateSessionReset,
canvasSessionIdChanged,
canvasSessionReset,
canvasQueueItemDiscarded,
} = canvasSessionSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrate = (state: any): any => { const migrate = (state: any): any => {
@@ -63,4 +80,34 @@ export const selectGenerateSessionId = createSelector(
selectCanvasSessionSlice, selectCanvasSessionSlice,
({ generateSessionId }) => generateSessionId ({ generateSessionId }) => generateSessionId
); );
export const selectIsStaging = createSelector(selectCanvasSessionId, (canvasSessionId) => canvasSessionId !== null); export const buildSelectSessionQueueItems = (sessionId: string) =>
createSelector(
[queueApi.endpoints.listAllQueueItems.select({ destination: sessionId }), selectDiscardedItems],
({ data }, discardedItems) => {
if (!data) {
return EMPTY_ARRAY;
}
return data.filter(
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
);
}
);
export const selectIsStaging = (state: RootState) => {
const sessionId = selectCanvasSessionId(state);
if (!sessionId) {
return false;
}
const { data } = queueApi.endpoints.listAllQueueItems.select({ destination: sessionId })(state);
if (!data) {
return false;
}
const discardedItems = selectDiscardedItems(state);
return data.some(
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
);
};
const selectDiscardedItems = createSelector(
selectCanvasSessionSlice,
({ canvasDiscardedQueueItems }) => canvasDiscardedQueueItems
);

View File

@@ -11,7 +11,7 @@ type LoRAsState = {
loras: LoRA[]; loras: LoRA[];
}; };
export const defaultLoRAConfig: Pick<LoRA, 'weight' | 'isEnabled'> = { const defaultLoRAConfig: Pick<LoRA, 'weight' | 'isEnabled'> = {
weight: 0.75, weight: 0.75,
isEnabled: true, isEnabled: true,
}; };

View File

@@ -1,9 +1,22 @@
import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import type { PayloadAction, Selector } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store'; import type { PersistConfig, RootState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple';
import { clamp } from 'es-toolkit/compat'; import { clamp } from 'es-toolkit/compat';
import type { ParamsState, RgbaColor } from 'features/controlLayers/store/types'; import type { AspectRatioID, ParamsState, RgbaColor } from 'features/controlLayers/store/types';
import { getInitialParamsState } from 'features/controlLayers/store/types'; import {
ASPECT_RATIO_MAP,
CHATGPT_ASPECT_RATIOS,
DEFAULT_ASPECT_RATIO_CONFIG,
FLUX_KONTEXT_ASPECT_RATIOS,
getInitialParamsState,
IMAGEN_ASPECT_RATIOS,
isChatGPT4oAspectRatioID,
isFluxKontextAspectRatioID,
isImagenAspectRatioID,
} from 'features/controlLayers/store/types';
import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
import { CLIP_SKIP_MAP } from 'features/parameters/types/constants'; import { CLIP_SKIP_MAP } from 'features/parameters/types/constants';
import type { import type {
ParameterCanvasCoherenceMode, ParameterCanvasCoherenceMode,
@@ -23,6 +36,7 @@ import type {
ParameterT5EncoderModel, ParameterT5EncoderModel,
ParameterVAEModel, ParameterVAEModel,
} from 'features/parameters/types/parameterSchemas'; } from 'features/parameters/types/parameterSchemas';
import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models';
import { isNonRefinerMainModelConfig } from 'services/api/types'; import { isNonRefinerMainModelConfig } from 'services/api/types';
@@ -186,6 +200,129 @@ export const paramsSlice = createSlice({
setCanvasCoherenceMinDenoise: (state, action: PayloadAction<number>) => { setCanvasCoherenceMinDenoise: (state, action: PayloadAction<number>) => {
state.canvasCoherenceMinDenoise = action.payload; state.canvasCoherenceMinDenoise = action.payload;
}, },
//#region Dimensions
widthChanged: (state, action: PayloadAction<{ width: number; updateAspectRatio?: boolean; clamp?: boolean }>) => {
const { width, updateAspectRatio, clamp } = action.payload;
const gridSize = getGridSize(state.model?.base);
state.dimensions.rect.width = clamp ? Math.max(roundDownToMultiple(width, gridSize), 64) : width;
if (state.dimensions.aspectRatio.isLocked) {
state.dimensions.rect.height = roundToMultiple(
state.dimensions.rect.width / state.dimensions.aspectRatio.value,
gridSize
);
}
if (updateAspectRatio || !state.dimensions.aspectRatio.isLocked) {
state.dimensions.aspectRatio.value = state.dimensions.rect.width / state.dimensions.rect.height;
state.dimensions.aspectRatio.id = 'Free';
state.dimensions.aspectRatio.isLocked = false;
}
},
heightChanged: (state, action: PayloadAction<{ height: number; updateAspectRatio?: boolean; clamp?: boolean }>) => {
const { height, updateAspectRatio, clamp } = action.payload;
const gridSize = getGridSize(state.model?.base);
state.dimensions.rect.height = clamp ? Math.max(roundDownToMultiple(height, gridSize), 64) : height;
if (state.dimensions.aspectRatio.isLocked) {
state.dimensions.rect.width = roundToMultiple(
state.dimensions.rect.height * state.dimensions.aspectRatio.value,
gridSize
);
}
if (updateAspectRatio || !state.dimensions.aspectRatio.isLocked) {
state.dimensions.aspectRatio.value = state.dimensions.rect.width / state.dimensions.rect.height;
state.dimensions.aspectRatio.id = 'Free';
state.dimensions.aspectRatio.isLocked = false;
}
},
aspectRatioLockToggled: (state) => {
state.dimensions.aspectRatio.isLocked = !state.dimensions.aspectRatio.isLocked;
},
aspectRatioIdChanged: (state, action: PayloadAction<{ id: AspectRatioID }>) => {
const { id } = action.payload;
state.dimensions.aspectRatio.id = id;
if (id === 'Free') {
state.dimensions.aspectRatio.isLocked = false;
} else if ((state.model?.base === 'imagen3' || state.model?.base === 'imagen4') && isImagenAspectRatioID(id)) {
const { width, height } = IMAGEN_ASPECT_RATIOS[id];
state.dimensions.rect.width = width;
state.dimensions.rect.height = height;
state.dimensions.aspectRatio.value = state.dimensions.rect.width / state.dimensions.rect.height;
state.dimensions.aspectRatio.isLocked = true;
} else if (state.model?.base === 'chatgpt-4o' && isChatGPT4oAspectRatioID(id)) {
const { width, height } = CHATGPT_ASPECT_RATIOS[id];
state.dimensions.rect.width = width;
state.dimensions.rect.height = height;
state.dimensions.aspectRatio.value = state.dimensions.rect.width / state.dimensions.rect.height;
state.dimensions.aspectRatio.isLocked = true;
} else if (state.model?.base === 'flux-kontext' && isFluxKontextAspectRatioID(id)) {
const { width, height } = FLUX_KONTEXT_ASPECT_RATIOS[id];
state.dimensions.rect.width = width;
state.dimensions.rect.height = height;
state.dimensions.aspectRatio.value = state.dimensions.rect.width / state.dimensions.rect.height;
state.dimensions.aspectRatio.isLocked = true;
} else {
state.dimensions.aspectRatio.isLocked = true;
state.dimensions.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio;
const { width, height } = calculateNewSize(
state.dimensions.aspectRatio.value,
state.dimensions.rect.width * state.dimensions.rect.height,
state.model?.base
);
state.dimensions.rect.width = width;
state.dimensions.rect.height = height;
}
},
dimensionsSwapped: (state) => {
state.dimensions.aspectRatio.value = 1 / state.dimensions.aspectRatio.value;
if (state.dimensions.aspectRatio.id === 'Free') {
const newWidth = state.dimensions.rect.height;
const newHeight = state.dimensions.rect.width;
state.dimensions.rect.width = newWidth;
state.dimensions.rect.height = newHeight;
} else {
const { width, height } = calculateNewSize(
state.dimensions.aspectRatio.value,
state.dimensions.rect.width * state.dimensions.rect.height,
state.model?.base
);
state.dimensions.rect.width = width;
state.dimensions.rect.height = height;
state.dimensions.aspectRatio.id = ASPECT_RATIO_MAP[state.dimensions.aspectRatio.id].inverseID;
}
},
sizeOptimized: (state) => {
const optimalDimension = getOptimalDimension(state.model?.base);
if (state.dimensions.aspectRatio.isLocked) {
const { width, height } = calculateNewSize(
state.dimensions.aspectRatio.value,
optimalDimension * optimalDimension,
state.model?.base
);
state.dimensions.rect.width = width;
state.dimensions.rect.height = height;
} else {
state.dimensions.aspectRatio = deepClone(DEFAULT_ASPECT_RATIO_CONFIG);
state.dimensions.rect.width = optimalDimension;
state.dimensions.rect.height = optimalDimension;
}
},
syncedToOptimalDimension: (state) => {
const optimalDimension = getOptimalDimension(state.model?.base);
if (!getIsSizeOptimal(state.dimensions.rect.width, state.dimensions.rect.height, state.model?.base)) {
const bboxDims = calculateNewSize(
state.dimensions.aspectRatio.value,
optimalDimension * optimalDimension,
state.model?.base
);
state.dimensions.rect.width = bboxDims.width;
state.dimensions.rect.height = bboxDims.height;
}
},
paramsReset: (state) => resetState(state), paramsReset: (state) => resetState(state),
}, },
}); });
@@ -249,6 +386,16 @@ export const {
setRefinerNegativeAestheticScore, setRefinerNegativeAestheticScore,
setRefinerStart, setRefinerStart,
modelChanged, modelChanged,
// Dimensions
widthChanged,
heightChanged,
aspectRatioLockToggled,
aspectRatioIdChanged,
dimensionsSwapped,
sizeOptimized,
syncedToOptimalDimension,
paramsReset, paramsReset,
} = paramsSlice.actions; } = paramsSlice.actions;
@@ -275,7 +422,16 @@ export const selectIsSD3 = createParamsSelector((params) => params.model?.base =
export const selectIsCogView4 = createParamsSelector((params) => params.model?.base === 'cogview4'); export const selectIsCogView4 = createParamsSelector((params) => params.model?.base === 'cogview4');
export const selectIsImagen3 = createParamsSelector((params) => params.model?.base === 'imagen3'); export const selectIsImagen3 = createParamsSelector((params) => params.model?.base === 'imagen3');
export const selectIsImagen4 = createParamsSelector((params) => params.model?.base === 'imagen4'); export const selectIsImagen4 = createParamsSelector((params) => params.model?.base === 'imagen4');
export const selectIsFluxKontext = createParamsSelector((params) => params.model?.base === 'flux-kontext'); export const selectIsFluxKontextApi = createParamsSelector((params) => params.model?.base === 'flux-kontext');
export const selectIsFluxKontext = createParamsSelector((params) => {
if (params.model?.base === 'flux-kontext') {
return true;
}
if (params.model?.base === 'flux' && params.model?.name.toLowerCase().includes('kontext')) {
return true;
}
return false;
});
export const selectIsChatGPT4o = createParamsSelector((params) => params.model?.base === 'chatgpt-4o'); export const selectIsChatGPT4o = createParamsSelector((params) => params.model?.base === 'chatgpt-4o');
export const selectModel = createParamsSelector((params) => params.model); export const selectModel = createParamsSelector((params) => params.model);
@@ -311,8 +467,8 @@ export const selectNegativePrompt = createParamsSelector((params) => params.nega
export const selectNegativePromptWithFallback = createParamsSelector((params) => params.negativePrompt ?? ''); export const selectNegativePromptWithFallback = createParamsSelector((params) => params.negativePrompt ?? '');
export const selectHasNegativePrompt = createParamsSelector((params) => params.negativePrompt !== null); export const selectHasNegativePrompt = createParamsSelector((params) => params.negativePrompt !== null);
export const selectModelSupportsNegativePrompt = createSelector( export const selectModelSupportsNegativePrompt = createSelector(
[selectIsFLUX, selectIsChatGPT4o], [selectIsFLUX, selectIsChatGPT4o, selectIsFluxKontext],
(isFLUX, isChatGPT4o) => !isFLUX && !isChatGPT4o (isFLUX, isChatGPT4o, isFluxKontext) => !isFLUX && !isChatGPT4o && !isFluxKontext
); );
export const selectPositivePrompt2 = createParamsSelector((params) => params.positivePrompt2); export const selectPositivePrompt2 = createParamsSelector((params) => params.positivePrompt2);
export const selectNegativePrompt2 = createParamsSelector((params) => params.negativePrompt2); export const selectNegativePrompt2 = createParamsSelector((params) => params.negativePrompt2);
@@ -342,6 +498,12 @@ export const selectRefinerScheduler = createParamsSelector((params) => params.re
export const selectRefinerStart = createParamsSelector((params) => params.refinerStart); export const selectRefinerStart = createParamsSelector((params) => params.refinerStart);
export const selectRefinerSteps = createParamsSelector((params) => params.refinerSteps); export const selectRefinerSteps = createParamsSelector((params) => params.refinerSteps);
export const selectWidth = createParamsSelector((params) => params.dimensions.rect.width);
export const selectHeight = createParamsSelector((params) => params.dimensions.rect.height);
export const selectAspectRatioID = createParamsSelector((params) => params.dimensions.aspectRatio.id);
export const selectAspectRatioValue = createParamsSelector((params) => params.dimensions.aspectRatio.value);
export const selectAspectRatioIsLocked = createParamsSelector((params) => params.dimensions.aspectRatio.isLocked);
export const selectMainModelConfig = createSelector( export const selectMainModelConfig = createSelector(
selectModelConfigsQuery, selectModelConfigsQuery,
selectParamsSlice, selectParamsSlice,

View File

@@ -5,10 +5,15 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import type { PersistConfig, RootState } from 'app/store/store'; import type { PersistConfig, RootState } from 'app/store/store';
import { clamp } from 'es-toolkit/compat'; import { clamp } from 'es-toolkit/compat';
import { getPrefixedId } from 'features/controlLayers/konva/util'; import { getPrefixedId } from 'features/controlLayers/konva/util';
import { canvasMetadataRecalled } from 'features/controlLayers/store/canvasSlice';
import type { FLUXReduxImageInfluence, RefImagesState } from 'features/controlLayers/store/types'; import type { FLUXReduxImageInfluence, RefImagesState } from 'features/controlLayers/store/types';
import { zModelIdentifierField } from 'features/nodes/types/common'; import { zModelIdentifierField } from 'features/nodes/types/common';
import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types'; import type {
ChatGPT4oModelConfig,
FLUXKontextModelConfig,
FLUXReduxModelConfig,
ImageDTO,
IPAdapterModelConfig,
} from 'services/api/types';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
@@ -48,9 +53,15 @@ export const refImagesSlice = createSlice({
payload: { ...payload, id: getPrefixedId('reference_image') }, payload: { ...payload, id: getPrefixedId('reference_image') },
}), }),
}, },
refImageRecalled: (state, action: PayloadAction<{ data: RefImageState }>) => { refImagesRecalled: (state, action: PayloadAction<{ entities: RefImageState[]; replace: boolean }>) => {
const { data } = action.payload; const { entities, replace } = action.payload;
state.entities.push(data); if (replace) {
state.entities = entities;
state.isPanelOpen = false;
state.selectedEntityId = null;
} else {
state.entities.push(...entities);
}
}, },
refImageImageChanged: (state, action: PayloadActionWithId<{ imageDTO: ImageDTO | null }>) => { refImageImageChanged: (state, action: PayloadActionWithId<{ imageDTO: ImageDTO | null }>) => {
const { id, imageDTO } = action.payload; const { id, imageDTO } = action.payload;
@@ -87,7 +98,9 @@ export const refImagesSlice = createSlice({
}, },
refImageModelChanged: ( refImageModelChanged: (
state, state,
action: PayloadActionWithId<{ modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig | null }> action: PayloadActionWithId<{
modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ChatGPT4oModelConfig | FLUXKontextModelConfig | null;
}>
) => { ) => {
const { id, modelConfig } = action.payload; const { id, modelConfig } = action.payload;
const entity = selectRefImageEntity(state, id); const entity = selectRefImageEntity(state, id);
@@ -122,7 +135,10 @@ export const refImagesSlice = createSlice({
return; return;
} }
if (entity.config.model.base === 'flux-kontext') { if (
entity.config.model.base === 'flux-kontext' ||
(entity.config.model.base === 'flux' && entity.config.model.name?.toLowerCase().includes('kontext'))
) {
// Switching to flux-kontext ref image // Switching to flux-kontext ref image
entity.config = { entity.config = {
...initialFluxKontextReferenceImage, ...initialFluxKontextReferenceImage,
@@ -232,12 +248,6 @@ export const refImagesSlice = createSlice({
}, },
refImagesReset: () => getInitialRefImagesState(), refImagesReset: () => getInitialRefImagesState(),
}, },
extraReducers(builder) {
builder.addCase(canvasMetadataRecalled, (state, action) => {
const { referenceImages } = action.payload;
state.entities = referenceImages;
});
},
}); });
export const { export const {
@@ -252,6 +262,7 @@ export const {
refImageIPAdapterBeginEndStepPctChanged, refImageIPAdapterBeginEndStepPctChanged,
refImageFLUXReduxImageInfluenceChanged, refImageFLUXReduxImageInfluenceChanged,
refImageIsEnabledToggled, refImageIsEnabledToggled,
refImagesRecalled,
} = refImagesSlice.actions; } = refImagesSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -276,12 +287,6 @@ export const selectRefImageEntityIds = createMemoizedSelector(selectReferenceIma
); );
export const selectRefImageEntity = (state: RefImagesState, id: string) => export const selectRefImageEntity = (state: RefImagesState, id: string) =>
state.entities.find((entity) => entity.id === id) ?? null; state.entities.find((entity) => entity.id === id) ?? null;
export const selectSelectedRefEntity = createSelector(selectRefImagesSlice, (state) => {
if (!state.selectedEntityId) {
return null;
}
return selectRefImageEntity(state, state.selectedEntityId);
});
export function selectRefImageEntityOrThrow(state: RefImagesState, id: string, caller: string): RefImageState { export function selectRefImageEntityOrThrow(state: RefImagesState, id: string, caller: string): RefImageState {
const entity = selectRefImageEntity(state, id); const entity = selectRefImageEntity(state, id);

View File

@@ -2,7 +2,6 @@ import type { Selector } from '@reduxjs/toolkit';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store'; import type { RootState } from 'app/store/store';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import { selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice';
import type { import type {
CanvasControlLayerState, CanvasControlLayerState,
CanvasEntityIdentifier, CanvasEntityIdentifier,
@@ -23,8 +22,7 @@ import { assert } from 'tsafe';
*/ */
export const selectCanvasSlice = (state: RootState) => state.canvas.present; export const selectCanvasSlice = (state: RootState) => state.canvas.present;
export const createCanvasSelector = <T>(selector: Selector<CanvasState, T>) => const createCanvasSelector = <T>(selector: Selector<CanvasState, T>) => createSelector(selectCanvasSlice, selector);
createSelector(selectCanvasSlice, selector);
/** /**
* Selects the total canvas entity count: * Selects the total canvas entity count:
@@ -67,36 +65,6 @@ export const selectActiveRegionalGuidanceEntities = createSelector(selectRegiona
entities.filter(isVisibleEntity) entities.filter(isVisibleEntity)
); );
/**
* Selects the total _active_ canvas entity count:
* - Regions
* - IP adapters
* - Raster layers
* - Control layers
* - Inpaint masks
*
* Active entities are those that are enabled and have at least one object.
*/
export const selectEntityCountActive = createSelector(
selectActiveRasterLayerEntities,
selectActiveControlLayerEntities,
selectActiveInpaintMaskEntities,
selectActiveRegionalGuidanceEntities,
(
activeRasterLayerEntities,
activeControlLayerEntities,
activeInpaintMaskEntities,
activeRegionalGuidanceEntities
) => {
return (
activeRasterLayerEntities.length +
activeControlLayerEntities.length +
activeInpaintMaskEntities.length +
activeRegionalGuidanceEntities.length
);
}
);
/** /**
* Selects if the canvas has any entities. * Selects if the canvas has any entities.
*/ */
@@ -377,10 +345,8 @@ export const selectBboxModelBase = createSelector(selectBbox, (bbox) => bbox.mod
export const selectCanvasMetadata = createSelector( export const selectCanvasMetadata = createSelector(
selectCanvasSlice, selectCanvasSlice,
selectReferenceImageEntities, (canvas): { canvas_v2_metadata: CanvasMetadata } => {
(canvas, refImageEntities): { canvas_v2_metadata: CanvasMetadata } => {
const canvas_v2_metadata: CanvasMetadata = { const canvas_v2_metadata: CanvasMetadata = {
referenceImages: refImageEntities,
controlLayers: selectAllEntitiesOfType(canvas, 'control_layer'), controlLayers: selectAllEntitiesOfType(canvas, 'control_layer'),
inpaintMasks: selectAllEntitiesOfType(canvas, 'inpaint_mask'), inpaintMasks: selectAllEntitiesOfType(canvas, 'inpaint_mask'),
rasterLayers: selectAllEntitiesOfType(canvas, 'raster_layer'), rasterLayers: selectAllEntitiesOfType(canvas, 'raster_layer'),
@@ -390,23 +356,6 @@ export const selectCanvasMetadata = createSelector(
} }
); );
export const selectIsCanvasEmpty = createCanvasSelector(
({ controlLayers, inpaintMasks, rasterLayers, regionalGuidance }) => {
// Check it all manually - could use lodash isEqual, but this selector will be called very often!
// Also note - we do not care about ref images, as they are technically not part of canvas
return (
controlLayers.entities.length === 0 &&
controlLayers.isHidden === false &&
inpaintMasks.entities.length === 0 &&
inpaintMasks.isHidden === false &&
rasterLayers.entities.length === 0 &&
rasterLayers.isHidden === false &&
regionalGuidance.entities.length === 0 &&
regionalGuidance.isHidden === false
);
}
);
/** /**
* Selects whether all non-raster layer categories (control layers, inpaint masks, regional guidance) are hidden. * Selects whether all non-raster layer categories (control layers, inpaint masks, regional guidance) are hidden.
* This is used to determine the state of the toggle button that shows/hides all non-raster layers. * This is used to determine the state of the toggle button that shows/hides all non-raster layers.

View File

@@ -30,7 +30,6 @@ import {
zParameterVAEModel, zParameterVAEModel,
} from 'features/parameters/types/parameterSchemas'; } from 'features/parameters/types/parameterSchemas';
import { getImageDTOSafe } from 'services/api/endpoints/images'; import { getImageDTOSafe } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest'; import type { JsonObject } from 'type-fest';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
@@ -99,7 +98,7 @@ const zRgbColor = z.object({
b: z.number().int().min(0).max(255), b: z.number().int().min(0).max(255),
}); });
export type RgbColor = z.infer<typeof zRgbColor>; export type RgbColor = z.infer<typeof zRgbColor>;
const zRgbaColor = zRgbColor.extend({ export const zRgbaColor = zRgbColor.extend({
a: z.number().min(0).max(1), a: z.number().min(0).max(1),
}); });
export type RgbaColor = z.infer<typeof zRgbaColor>; export type RgbaColor = z.infer<typeof zRgbaColor>;
@@ -300,10 +299,9 @@ const zCanvasEntityBase = z.object({
isLocked: z.boolean(), isLocked: z.boolean(),
}); });
const zRefImageState = z.object({ export const zRefImageState = z.object({
id: zId, id: zId,
isEnabled: z.boolean().default(true), isEnabled: z.boolean().default(true),
// This should be named `referenceImage` but we need to keep it as `ipAdapter` for backwards compatibility
config: z.discriminatedUnion('type', [ config: z.discriminatedUnion('type', [
zIPAdapterConfig, zIPAdapterConfig,
zFLUXReduxConfig, zFLUXReduxConfig,
@@ -384,7 +382,7 @@ const zControlLoRAConfig = z.object({
}); });
export type ControlLoRAConfig = z.infer<typeof zControlLoRAConfig>; export type ControlLoRAConfig = z.infer<typeof zControlLoRAConfig>;
export const zCanvasRasterLayerState = zCanvasEntityBase.extend({ const zCanvasRasterLayerState = zCanvasEntityBase.extend({
type: z.literal('raster_layer'), type: z.literal('raster_layer'),
position: zCoordinate, position: zCoordinate,
opacity: zOpacity, opacity: zOpacity,
@@ -433,35 +431,57 @@ export type LoRA = {
weight: number; weight: number;
}; };
export type StagingAreaImage = {
type: 'staged';
sessionId: string;
imageDTO: ImageDTO;
offsetX: number;
offsetY: number;
};
export type StagingAreaProgressImage = {
type: 'progress';
sessionId: string;
};
export type EphemeralProgressImage = { sessionId: string; image: ProgressImage }; export type EphemeralProgressImage = { sessionId: string; image: ProgressImage };
export const zAspectRatioID = z.enum(['Free', '21:9', '9:21', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16']); export const zAspectRatioID = z.enum(['Free', '21:9', '9:21', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16']);
export const zImagen3AspectRatioID = z.enum(['16:9', '4:3', '1:1', '3:4', '9:16']);
export const isImagenAspectRatioID = (v: unknown): v is z.infer<typeof zImagen3AspectRatioID> =>
zImagen3AspectRatioID.safeParse(v).success;
export const zChatGPT4oAspectRatioID = z.enum(['3:2', '1:1', '2:3']);
export const isChatGPT4oAspectRatioID = (v: unknown): v is z.infer<typeof zChatGPT4oAspectRatioID> =>
zChatGPT4oAspectRatioID.safeParse(v).success;
export const zFluxKontextAspectRatioID = z.enum(['21:9', '4:3', '1:1', '3:4', '9:21', '16:9', '9:16']);
export const isFluxKontextAspectRatioID = (v: unknown): v is z.infer<typeof zFluxKontextAspectRatioID> =>
zFluxKontextAspectRatioID.safeParse(v).success;
export type AspectRatioID = z.infer<typeof zAspectRatioID>; export type AspectRatioID = z.infer<typeof zAspectRatioID>;
export const isAspectRatioID = (v: unknown): v is AspectRatioID => zAspectRatioID.safeParse(v).success; export const isAspectRatioID = (v: unknown): v is AspectRatioID => zAspectRatioID.safeParse(v).success;
export const ASPECT_RATIO_MAP: Record<Exclude<AspectRatioID, 'Free'>, { ratio: number; inverseID: AspectRatioID }> = {
'21:9': { ratio: 21 / 9, inverseID: '9:21' },
'16:9': { ratio: 16 / 9, inverseID: '9:16' },
'3:2': { ratio: 3 / 2, inverseID: '2:3' },
'4:3': { ratio: 4 / 3, inverseID: '4:3' },
'1:1': { ratio: 1, inverseID: '1:1' },
'3:4': { ratio: 3 / 4, inverseID: '4:3' },
'2:3': { ratio: 2 / 3, inverseID: '3:2' },
'9:16': { ratio: 9 / 16, inverseID: '16:9' },
'9:21': { ratio: 9 / 21, inverseID: '21:9' },
};
export const zImagen3AspectRatioID = z.enum(['16:9', '4:3', '1:1', '3:4', '9:16']);
type ImagenAspectRatio = z.infer<typeof zImagen3AspectRatioID>;
export const isImagenAspectRatioID = (v: unknown): v is ImagenAspectRatio => zImagen3AspectRatioID.safeParse(v).success;
export const IMAGEN_ASPECT_RATIOS: Record<ImagenAspectRatio, Dimensions> = {
'16:9': { width: 1408, height: 768 },
'4:3': { width: 1280, height: 896 },
'1:1': { width: 1024, height: 1024 },
'3:4': { width: 896, height: 1280 },
'9:16': { width: 768, height: 1408 },
};
export const zChatGPT4oAspectRatioID = z.enum(['3:2', '1:1', '2:3']);
type ChatGPT4oAspectRatio = z.infer<typeof zChatGPT4oAspectRatioID>;
export const isChatGPT4oAspectRatioID = (v: unknown): v is ChatGPT4oAspectRatio =>
zChatGPT4oAspectRatioID.safeParse(v).success;
export const CHATGPT_ASPECT_RATIOS: Record<ChatGPT4oAspectRatio, Dimensions> = {
'3:2': { width: 1536, height: 1024 },
'1:1': { width: 1024, height: 1024 },
'2:3': { width: 1024, height: 1536 },
} as const;
export const zFluxKontextAspectRatioID = z.enum(['21:9', '4:3', '1:1', '3:4', '9:21', '16:9', '9:16']);
type FluxKontextAspectRatio = z.infer<typeof zFluxKontextAspectRatioID>;
export const isFluxKontextAspectRatioID = (v: unknown): v is z.infer<typeof zFluxKontextAspectRatioID> =>
zFluxKontextAspectRatioID.safeParse(v).success;
export const FLUX_KONTEXT_ASPECT_RATIOS: Record<FluxKontextAspectRatio, Dimensions> = {
'3:4': { width: 880, height: 1184 },
'4:3': { width: 1184, height: 880 },
'9:16': { width: 752, height: 1392 },
'16:9': { width: 1392, height: 752 },
'21:9': { width: 1568, height: 672 },
'9:21': { width: 672, height: 1568 },
'1:1': { width: 1024, height: 1024 },
};
const zAspectRatioConfig = z.object({ const zAspectRatioConfig = z.object({
id: zAspectRatioID, id: zAspectRatioID,
@@ -492,6 +512,16 @@ const zBboxState = z.object({
modelBase: zMainModelBase, modelBase: zMainModelBase,
}); });
const zDimensionsState = z.object({
rect: z.object({
x: z.number().int(),
y: z.number().int(),
width: zParameterImageDimension,
height: zParameterImageDimension,
}),
aspectRatio: zAspectRatioConfig,
});
const zParamsState = z.object({ const zParamsState = z.object({
maskBlur: z.number().default(16), maskBlur: z.number().default(16),
maskBlurMethod: zParameterMaskBlurMethod.default('box'), maskBlurMethod: zParameterMaskBlurMethod.default('box'),
@@ -540,6 +570,10 @@ const zParamsState = z.object({
clipLEmbedModel: zParameterCLIPLEmbedModel.nullable().default(null), clipLEmbedModel: zParameterCLIPLEmbedModel.nullable().default(null),
clipGEmbedModel: zParameterCLIPGEmbedModel.nullable().default(null), clipGEmbedModel: zParameterCLIPGEmbedModel.nullable().default(null),
controlLora: zParameterControlLoRAModel.nullable().default(null), controlLora: zParameterControlLoRAModel.nullable().default(null),
dimensions: zDimensionsState.default({
rect: { x: 0, y: 0, width: 512, height: 512 },
aspectRatio: DEFAULT_ASPECT_RATIO_CONFIG,
}),
}); });
export type ParamsState = z.infer<typeof zParamsState>; export type ParamsState = z.infer<typeof zParamsState>;
const INITIAL_PARAMS_STATE = zParamsState.parse({}); const INITIAL_PARAMS_STATE = zParamsState.parse({});
@@ -594,12 +628,17 @@ export const getInitialRefImagesState = () => deepClone(INITIAL_REF_IMAGES_STATE
const CANVAS_INITIAL_STATE = zCanvasState.parse({}); const CANVAS_INITIAL_STATE = zCanvasState.parse({});
export const getInitialCanvasState = () => deepClone(CANVAS_INITIAL_STATE); export const getInitialCanvasState = () => deepClone(CANVAS_INITIAL_STATE);
export const zCanvasReferenceImageState_OLD = zCanvasEntityBase.extend({
type: z.literal('reference_image'),
ipAdapter: z.discriminatedUnion('type', [zIPAdapterConfig, zFLUXReduxConfig, zChatGPT4oReferenceImageConfig]),
});
export const zCanvasMetadata = z.object({ export const zCanvasMetadata = z.object({
inpaintMasks: z.array(zCanvasInpaintMaskState), inpaintMasks: z.array(zCanvasInpaintMaskState),
rasterLayers: z.array(zCanvasRasterLayerState), rasterLayers: z.array(zCanvasRasterLayerState),
controlLayers: z.array(zCanvasControlLayerState), controlLayers: z.array(zCanvasControlLayerState),
regionalGuidance: z.array(zCanvasRegionalGuidanceState), regionalGuidance: z.array(zCanvasRegionalGuidanceState),
referenceImages: z.array(zRefImageState), // referenceImages: z.array(zRefImageState),
}); });
export type CanvasMetadata = z.infer<typeof zCanvasMetadata>; export type CanvasMetadata = z.infer<typeof zCanvasMetadata>;

View File

@@ -5,7 +5,8 @@ import type {
CanvasRegionalGuidanceState, CanvasRegionalGuidanceState,
RefImageState, RefImageState,
} from 'features/controlLayers/store/types'; } from 'features/controlLayers/store/types';
import type { MainModelConfig } from 'services/api/types'; import type { ModelIdentifierField } from 'features/nodes/types/common';
import type { AnyModelConfig, MainModelConfig } from 'services/api/types';
const WARNINGS = { const WARNINGS = {
UNSUPPORTED_MODEL: 'controlLayers.warnings.unsupportedModel', UNSUPPORTED_MODEL: 'controlLayers.warnings.unsupportedModel',
@@ -77,6 +78,27 @@ export const getRegionalGuidanceWarnings = (
return warnings; return warnings;
}; };
export const areBasesCompatibleForRefImage = (
first?: ModelIdentifierField | AnyModelConfig | null,
second?: ModelIdentifierField | AnyModelConfig | null
): boolean => {
if (!first || !second) {
return false;
}
if (first.base !== second.base) {
return false;
}
if (
first.base === 'flux' &&
(first.name.toLowerCase().includes('kontext') || second.name.toLowerCase().includes('kontext')) &&
first.key !== second.key
) {
// FLUX Kontext requires the main model and the reference image model to be the same model
return false;
}
return true;
};
export const getGlobalReferenceImageWarnings = ( export const getGlobalReferenceImageWarnings = (
entity: RefImageState, entity: RefImageState,
model: MainModelConfig | null | undefined model: MainModelConfig | null | undefined
@@ -95,7 +117,7 @@ export const getGlobalReferenceImageWarnings = (
if (!config.model) { if (!config.model) {
// No model selected // No model selected
warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED); warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED);
} else if (config.model.base !== model.base) { } else if (!areBasesCompatibleForRefImage(config.model, model)) {
// Supported model architecture but doesn't match // Supported model architecture but doesn't match
warnings.push(WARNINGS.IP_ADAPTER_INCOMPATIBLE_BASE_MODEL); warnings.push(WARNINGS.IP_ADAPTER_INCOMPATIBLE_BASE_MODEL);
} }

View File

@@ -11,6 +11,7 @@ import {
import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasState, RefImagesState } from 'features/controlLayers/store/types'; import type { CanvasState, RefImagesState } from 'features/controlLayers/store/types';
import type { ImageUsage } from 'features/deleteImageModal/store/types'; import type { ImageUsage } from 'features/deleteImageModal/store/types';
import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors'; import { selectNodesSlice } from 'features/nodes/store/selectors';
@@ -79,11 +80,18 @@ const handleDeletions = async (image_names: string[], store: AppStore) => {
try { try {
const { dispatch, getState } = store; const { dispatch, getState } = store;
const state = getState(); const state = getState();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap(); const { data } = imagesApi.endpoints.getImageNames.select(selectGetImageNamesQueryArgs(state))(state);
const index = data?.image_names.findIndex((name) => name === image_names[0]);
const { deleted_images } = await dispatch(
imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })
).unwrap();
const newImageNames = data?.image_names.filter((name) => !deleted_images.includes(name)) || [];
const newSelectedImage = newImageNames[index ?? 0] || null;
if (intersection(state.gallery.selection, image_names).length > 0) { if (intersection(state.gallery.selection, image_names).length > 0) {
// Some selected images were deleted, clear selection // Some selected images were deleted, clear selection
dispatch(imageSelected(null)); dispatch(imageSelected(newSelectedImage));
} }
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist // We need to reset the features where the image is in use - none of these work if their image(s) don't exist

View File

@@ -1,10 +1,3 @@
import type { ImageDTO } from 'services/api/types';
export type DeleteImageState = {
imagesToDelete: ImageDTO[];
isModalOpen: boolean;
};
export type ImageUsage = { export type ImageUsage = {
isUpscaleImage: boolean; isUpscaleImage: boolean;
isRasterLayerImage: boolean; isRasterLayerImage: boolean;

View File

@@ -15,7 +15,6 @@ const sx = {
objectFit: 'contain', objectFit: 'contain',
maxW: 'full', maxW: 'full',
maxH: 'full', maxH: 'full',
borderRadius: 'base',
cursor: 'grab', cursor: 'grab',
'&[data-is-dragging=true]': { '&[data-is-dragging=true]': {
opacity: 0.3, opacity: 0.3,

View File

@@ -159,7 +159,7 @@ export const setGlobalReferenceImageDndTarget: DndTarget<
//#region Add Global Reference Image //#region Add Global Reference Image
const _addGlobalReferenceImage = buildTypeAndKey('add-global-reference-image'); const _addGlobalReferenceImage = buildTypeAndKey('add-global-reference-image');
export type AddGlobalReferenceImageDndTargetData = DndData< type AddGlobalReferenceImageDndTargetData = DndData<
typeof _addGlobalReferenceImage.type, typeof _addGlobalReferenceImage.type,
typeof _addGlobalReferenceImage.key typeof _addGlobalReferenceImage.key
>; >;
@@ -352,7 +352,10 @@ const _newCanvasEntity = buildTypeAndKey('new-canvas-entity-from-image');
type NewCanvasEntityFromImageDndTargetData = DndData< type NewCanvasEntityFromImageDndTargetData = DndData<
typeof _newCanvasEntity.type, typeof _newCanvasEntity.type,
typeof _newCanvasEntity.key, typeof _newCanvasEntity.key,
{ type: CanvasEntityType | 'regional_guidance_with_reference_image' } {
type: CanvasEntityType | 'regional_guidance_with_reference_image';
withResize?: boolean;
}
>; >;
export const newCanvasEntityFromImageDndTarget: DndTarget< export const newCanvasEntityFromImageDndTarget: DndTarget<
NewCanvasEntityFromImageDndTargetData, NewCanvasEntityFromImageDndTargetData,
@@ -368,20 +371,20 @@ export const newCanvasEntityFromImageDndTarget: DndTarget<
return true; return true;
}, },
handler: ({ sourceData, targetData, dispatch, getState }) => { handler: ({ sourceData, targetData, dispatch, getState }) => {
const { type } = targetData.payload; const { type, withResize } = targetData.payload;
const { imageDTO } = sourceData.payload; const { imageDTO } = sourceData.payload;
createNewCanvasEntityFromImage({ type, imageDTO, dispatch, getState }); createNewCanvasEntityFromImage({ type, imageDTO, withResize, dispatch, getState });
}, },
}; };
//#endregion //#endregion
//#region New Canvas from Image //#region New Canvas from Image
const _newCanvas = buildTypeAndKey('new-canvas-entity-from-image'); const _newCanvas = buildTypeAndKey('new-canvas-from-image');
type NewCanvasFromImageDndTargetData = DndData< type NewCanvasFromImageDndTargetData = DndData<
typeof _newCanvas.type, typeof _newCanvas.type,
typeof _newCanvas.key, typeof _newCanvas.key,
{ {
type: CanvasEntityType | 'regional_guidance_with_reference_image' | 'reference_image'; type: CanvasEntityType | 'regional_guidance_with_reference_image';
withResize?: boolean; withResize?: boolean;
withInpaintMask?: boolean; withInpaintMask?: boolean;
} }
@@ -519,7 +522,7 @@ export const removeImageFromBoardDndTarget: DndTarget<
//#region Prompt Generation From Image //#region Prompt Generation From Image
const _promptGenerationFromImage = buildTypeAndKey('prompt-generation-from-image'); const _promptGenerationFromImage = buildTypeAndKey('prompt-generation-from-image');
export type PromptGenerationFromImageDndTargetData = DndData< type PromptGenerationFromImageDndTargetData = DndData<
typeof _promptGenerationFromImage.type, typeof _promptGenerationFromImage.type,
typeof _promptGenerationFromImage.key, typeof _promptGenerationFromImage.key,
void void

View File

@@ -59,13 +59,11 @@ export const useDynamicPromptsWatcher = () => {
return; return;
} }
const { positivePrompt } = presetModifiedPrompts;
// Before we execute, imperatively check the dynamic prompts query cache to see if we have already fetched this prompt // Before we execute, imperatively check the dynamic prompts query cache to see if we have already fetched this prompt
const state = getState(); const state = getState();
const cachedPrompts = utilitiesApi.endpoints.dynamicPrompts.select({ const cachedPrompts = utilitiesApi.endpoints.dynamicPrompts.select({
prompt: positivePrompt, prompt: presetModifiedPrompts.positive,
max_prompts: maxPrompts, max_prompts: maxPrompts,
})(state).data; })(state).data;
@@ -77,8 +75,8 @@ export const useDynamicPromptsWatcher = () => {
} }
// If the prompt is not in the cache, check if we should process it - this is just looking for dynamic prompts syntax // If the prompt is not in the cache, check if we should process it - this is just looking for dynamic prompts syntax
if (!getShouldProcessPrompt(positivePrompt)) { if (!getShouldProcessPrompt(presetModifiedPrompts.positive)) {
dispatch(promptsChanged([positivePrompt])); dispatch(promptsChanged([presetModifiedPrompts.positive]));
dispatch(parsingErrorChanged(undefined)); dispatch(parsingErrorChanged(undefined));
dispatch(isErrorChanged(false)); dispatch(isErrorChanged(false));
return; return;
@@ -89,6 +87,6 @@ export const useDynamicPromptsWatcher = () => {
dispatch(isLoadingChanged(true)); dispatch(isLoadingChanged(true));
} }
debouncedUpdateDynamicPrompts(positivePrompt, maxPrompts); debouncedUpdateDynamicPrompts(presetModifiedPrompts.positive, maxPrompts);
}, [debouncedUpdateDynamicPrompts, dispatch, dynamicPrompting, getState, maxPrompts, presetModifiedPrompts]); }, [debouncedUpdateDynamicPrompts, dispatch, dynamicPrompting, getState, maxPrompts, presetModifiedPrompts]);
}; };

View File

@@ -9,7 +9,12 @@ import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { BOARD_PANEL_DEFAULT_HEIGHT_PX, BOARD_PANEL_MIN_HEIGHT_PX, BOARDS_PANEL_ID } from 'features/ui/layouts/shared'; import {
BOARD_PANEL_DEFAULT_HEIGHT_PX,
BOARD_PANEL_MIN_EXPANDED_HEIGHT_PX,
BOARD_PANEL_MIN_HEIGHT_PX,
BOARDS_PANEL_ID,
} from 'features/ui/layouts/shared';
import { useCollapsibleGridviewPanel } from 'features/ui/layouts/use-collapsible-gridview-panel'; import { useCollapsibleGridviewPanel } from 'features/ui/layouts/use-collapsible-gridview-panel';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
@@ -24,11 +29,11 @@ export const BoardsPanel = memo(() => {
const { tab } = useAutoLayoutContext(); const { tab } = useAutoLayoutContext();
const collapsibleApi = useCollapsibleGridviewPanel( const collapsibleApi = useCollapsibleGridviewPanel(
tab, tab,
'right',
BOARDS_PANEL_ID, BOARDS_PANEL_ID,
'vertical', 'vertical',
BOARD_PANEL_DEFAULT_HEIGHT_PX, BOARD_PANEL_DEFAULT_HEIGHT_PX,
BOARD_PANEL_MIN_HEIGHT_PX BOARD_PANEL_MIN_HEIGHT_PX,
BOARD_PANEL_MIN_EXPANDED_HEIGHT_PX
); );
const isCollapsed = useStore(collapsibleApi.$isCollapsed); const isCollapsed = useStore(collapsibleApi.$isCollapsed);
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -10,6 +10,7 @@ import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { import {
GALLERY_PANEL_DEFAULT_HEIGHT_PX, GALLERY_PANEL_DEFAULT_HEIGHT_PX,
GALLERY_PANEL_ID, GALLERY_PANEL_ID,
GALLERY_PANEL_MIN_EXPANDED_HEIGHT_PX,
GALLERY_PANEL_MIN_HEIGHT_PX, GALLERY_PANEL_MIN_HEIGHT_PX,
} from 'features/ui/layouts/shared'; } from 'features/ui/layouts/shared';
import { useCollapsibleGridviewPanel } from 'features/ui/layouts/use-collapsible-gridview-panel'; import { useCollapsibleGridviewPanel } from 'features/ui/layouts/use-collapsible-gridview-panel';
@@ -35,11 +36,11 @@ export const GalleryPanel = memo(() => {
const { tab } = useAutoLayoutContext(); const { tab } = useAutoLayoutContext();
const collapsibleApi = useCollapsibleGridviewPanel( const collapsibleApi = useCollapsibleGridviewPanel(
tab, tab,
'right',
GALLERY_PANEL_ID, GALLERY_PANEL_ID,
'vertical', 'vertical',
GALLERY_PANEL_DEFAULT_HEIGHT_PX, GALLERY_PANEL_DEFAULT_HEIGHT_PX,
GALLERY_PANEL_MIN_HEIGHT_PX GALLERY_PANEL_MIN_HEIGHT_PX,
GALLERY_PANEL_MIN_EXPANDED_HEIGHT_PX
); );
const isCollapsed = useStore(collapsibleApi.$isCollapsed); const isCollapsed = useStore(collapsibleApi.$isCollapsed);

View File

@@ -1,25 +1,33 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useImageActions } from 'features/gallery/hooks/useImageActions'; import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
PiArrowBendUpLeftBold, PiArrowBendUpLeftBold,
PiArrowsCounterClockwiseBold, PiArrowsCounterClockwiseBold,
PiAsteriskBold, PiAsteriskBold,
PiPaintBrushBold,
PiPlantBold, PiPlantBold,
PiQuotesBold, PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi'; } from 'react-icons/pi';
export const ImageMenuItemMetadataRecallActions = memo(() => { export const ImageMenuItemMetadataRecallActions = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const subMenu = useSubMenu(); const subMenu = useSubMenu();
const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, createAsPreset } = const imageDTO = useImageDTOContext();
useImageActions(imageDTO);
const recallAll = useRecallAll(imageDTO);
const recallRemix = useRecallRemix(imageDTO);
const recallPrompts = useRecallPrompts(imageDTO);
const recallSeed = useRecallSeed(imageDTO);
const recallDimensions = useRecallDimensions(imageDTO);
return ( return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiArrowBendUpLeftBold />}> <MenuItem {...subMenu.parentMenuItemProps} icon={<PiArrowBendUpLeftBold />}>
@@ -28,20 +36,24 @@ export const ImageMenuItemMetadataRecallActions = memo(() => {
<SubMenuButtonContent label={t('parameters.recallMetadata')} /> <SubMenuButtonContent label={t('parameters.recallMetadata')} />
</MenuButton> </MenuButton>
<MenuList {...subMenu.menuListProps}> <MenuList {...subMenu.menuListProps}>
<MenuItem icon={<PiArrowsCounterClockwiseBold />} onClick={remix} isDisabled={!hasMetadata}> <MenuItem
icon={<PiArrowsCounterClockwiseBold />}
onClick={recallRemix.recall}
isDisabled={!recallRemix.isEnabled}
>
{t('parameters.remixImage')} {t('parameters.remixImage')}
</MenuItem> </MenuItem>
<MenuItem icon={<PiQuotesBold />} onClick={recallPrompts} isDisabled={!hasPrompts}> <MenuItem icon={<PiQuotesBold />} onClick={recallPrompts.recall} isDisabled={!recallPrompts.isEnabled}>
{t('parameters.usePrompt')} {t('parameters.usePrompt')}
</MenuItem> </MenuItem>
<MenuItem icon={<PiPlantBold />} onClick={recallSeed} isDisabled={!hasSeed}> <MenuItem icon={<PiPlantBold />} onClick={recallSeed.recall} isDisabled={!recallSeed.isEnabled}>
{t('parameters.useSeed')} {t('parameters.useSeed')}
</MenuItem> </MenuItem>
<MenuItem icon={<PiAsteriskBold />} onClick={recallAll} isDisabled={!hasMetadata}> <MenuItem icon={<PiAsteriskBold />} onClick={recallAll.recall} isDisabled={!recallAll.isEnabled}>
{t('parameters.useAll')} {t('parameters.useAll')}
</MenuItem> </MenuItem>
<MenuItem icon={<PiPaintBrushBold />} onClick={createAsPreset} isDisabled={!hasPrompts}> <MenuItem icon={<PiRulerBold />} onClick={recallDimensions.recall} isDisabled={!recallDimensions.isEnabled}>
{t('stylePresets.useForTemplate')} {t('parameters.useSize')}
</MenuItem> </MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>

View File

@@ -20,8 +20,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => { const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState }); await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),
@@ -31,8 +31,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => { const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState }); await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),
@@ -42,8 +42,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => { const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState }); await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),
@@ -53,8 +53,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => { const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState }); await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),

View File

@@ -20,11 +20,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const imageDTO = useImageDTOContext(); const imageDTO = useImageDTOContext();
const isBusy = useCanvasIsBusySafe(); const isBusy = useCanvasIsBusySafe();
const onClickNewRasterLayerFromImage = useCallback(() => { const onClickNewRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState }); createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
dispatch(sentImageToCanvas()); dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),
@@ -32,11 +32,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
}); });
}, [imageDTO, store, t]); }, [imageDTO, store, t]);
const onClickNewControlLayerFromImage = useCallback(() => { const onClickNewControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState }); createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
dispatch(sentImageToCanvas()); dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),
@@ -44,11 +44,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
}); });
}, [imageDTO, store, t]); }, [imageDTO, store, t]);
const onClickNewInpaintMaskFromImage = useCallback(() => { const onClickNewInpaintMaskFromImage = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState }); createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState });
dispatch(sentImageToCanvas()); dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),
@@ -56,11 +56,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
}); });
}, [imageDTO, store, t]); }, [imageDTO, store, t]);
const onClickNewRegionalGuidanceFromImage = useCallback(() => { const onClickNewRegionalGuidanceFromImage = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState }); createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState });
dispatch(sentImageToCanvas()); dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),
@@ -68,11 +68,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
}); });
}, [imageDTO, store, t]); }, [imageDTO, store, t]);
const onClickNewRegionalReferenceImageFromImage = useCallback(() => { const onClickNewRegionalReferenceImageFromImage = useCallback(async () => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState }); createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState });
dispatch(sentImageToCanvas()); dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
toast({ toast({
id: 'SENT_TO_CANVAS', id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'), title: t('toast.sentToCanvas'),

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