Compare commits

..

165 Commits

Author SHA1 Message Date
psychedelicious
005402b0a7 chore: ruff 2025-06-25 01:20:04 +10:00
psychedelicious
a6baeba357 chore: bump version to v6.0.0a6 2025-06-25 01:14:12 +10:00
psychedelicious
ccdd58838a fix(ui): minor jank when siwtching images rapidly 2025-06-25 01:10:49 +10:00
psychedelicious
e4170df91d feat(ui): scrollbar styles 2025-06-25 01:06:02 +10:00
psychedelicious
190a7eef58 refactor: gallery scroll (improved impl) 2025-06-25 00:51:16 +10:00
psychedelicious
18a93a5164 refactor: gallery scroll (improved impl) 2025-06-25 00:38:28 +10:00
psychedelicious
a1cf3af732 refactor: gallery scroll (improved impl) 2025-06-24 23:33:37 +10:00
psychedelicious
4f191fe4b3 refactor: gallery scroll (improved impl) 2025-06-24 23:00:24 +10:00
psychedelicious
097d0da09f refactor: gallery scroll (improved impl) 2025-06-24 21:35:41 +10:00
psychedelicious
184bcfaf06 refactor: gallery scroll (improved impl) 2025-06-24 21:31:37 +10:00
psychedelicious
994236e9a8 refactor: gallery scroll (improved impl) 2025-06-24 21:01:29 +10:00
psychedelicious
74004aea04 refactor: gallery scroll (improved impl) 2025-06-24 20:46:45 +10:00
psychedelicious
ab27832d0c refactor: gallery scroll (improved impl) 2025-06-24 19:58:59 +10:00
psychedelicious
ef3d260657 refactor: gallery scroll (improved impl) 2025-06-24 17:23:04 +10:00
psychedelicious
4c462c2423 refactor: gallery scroll (improved impl) 2025-06-24 16:03:29 +10:00
psychedelicious
f797061390 refactor: gallery scroll 2025-06-24 15:51:28 +10:00
psychedelicious
83cca78e7c fix(ui): fix metadata toggle stuck disabled 2025-06-24 06:07:17 +10:00
psychedelicious
89cd24d3e2 chore: bump version to v6.0.0a5 2025-06-24 05:41:21 +10:00
psychedelicious
ae3e9f0007 chore(ui): lint 2025-06-24 05:41:21 +10:00
psychedelicious
7a5d0a8973 refactor(ui): use image names for selection instead of dtos
Update the frontend to incorporate the previous changes to how image
selection and general image identification is handled in the frontend.
2025-06-24 05:41:21 +10:00
psychedelicious
515e270908 chore(ui): typegen 2025-06-24 05:41:20 +10:00
psychedelicious
69cd265124 feat(api): return more data when doing image/board mutations
When we delete images, boards, or do any other board mutation, we need
to invalidate numerous query caches and related internal frontend state.
This gets complicated very quickly.

We can drastically reduce the complexity by having the backend return
some more information when we make these mutations.

For example, when deleting a list of images by name, we can return a
list of deleted image name and affected boards. The frontend can use
this information to determine which queries to invalidate with far less
tedium.

This will also enable the more efficient storage of images (e.g. in the
gallery selection). Previously, we had to store the entire image DTO
object, else we wouldn't be able to figure out which queries to
invalidate. But now that the backend tells us exactly what images/boards
have changed, we can just store image names in frontend state. This
amounts to a substantial improvement in DX and reduction in frontend
complexity.
2025-06-24 05:41:20 +10:00
psychedelicious
88164ed268 feat(ui): viewer integrates progress (wip) 2025-06-24 05:41:20 +10:00
psychedelicious
8566ede81a feat(ui): switch to viewer/canvas on invoke 2025-06-24 05:41:20 +10:00
psychedelicious
1690d10197 feat(ui): generation progress tab improvements 2025-06-24 05:41:20 +10:00
psychedelicious
5ec022323a feat(ui): show last progress message & placeholder in generation progress panel 2025-06-24 05:41:20 +10:00
psychedelicious
90815551d6 fix(ui): staging area does not show placeholder on first render 2025-06-24 05:41:20 +10:00
psychedelicious
dac23c54c9 feat(ui): double-click staging area image to disable auto-switch 2025-06-24 05:41:20 +10:00
psychedelicious
da116eb09b fix(ui): reset last started item id when doing autoswitch 2025-06-24 05:41:20 +10:00
psychedelicious
5f8e21e809 feat(ui): re-implement multiple auto-switch modes 2025-06-24 05:41:20 +10:00
psychedelicious
8d53cbbcdd chore: bump version to v6.0.0a4 2025-06-24 05:41:20 +10:00
psychedelicious
baac5f06d6 feat(ui): no model error state for ref images 2025-06-24 05:41:20 +10:00
psychedelicious
cf781a3b99 feat(ui): mini metadata viewer 2025-06-24 05:41:20 +10:00
psychedelicious
1e47e6fe0a feat(ui): clean up image view components & code 2025-06-24 05:41:19 +10:00
psychedelicious
cb15841eaf fix(ui): launchpad layouts 2025-06-24 05:41:19 +10:00
psychedelicious
527d89d07b fix(ui): don't use layers when generating on generate tab 2025-06-24 05:41:19 +10:00
psychedelicious
d36f02a20f feat(ui): tweak vertical tab bar layout 2025-06-24 05:41:19 +10:00
psychedelicious
432c65795a fix(ui): unable to resize prompt box bc negative prompt button is over
the handle
2025-06-24 05:41:19 +10:00
psychedelicious
c734924ea5 feat(ui): standardize auto layout structure 2025-06-24 05:41:19 +10:00
psychedelicious
b61e6d5760 feat(ui): tweak dockview tabs 2025-06-24 05:41:19 +10:00
psychedelicious
06289da0c9 refactor(ui): rip out image viewer as modal 2025-06-24 05:41:19 +10:00
psychedelicious
d97eb84c4e chore: bump version to v6.0.0a3 2025-06-24 05:41:19 +10:00
psychedelicious
15f212b9f0 chore(ui): lint 2025-06-24 05:41:19 +10:00
psychedelicious
5e573119ab feat(ui): restore all panel hotkeys 2025-06-24 05:41:19 +10:00
psychedelicious
20effc5da6 fix(ui): generate tab hotkey 2025-06-24 05:41:19 +10:00
psychedelicious
c2266da827 feat(ui): restore floating panel buttons 2025-06-24 05:41:18 +10:00
psychedelicious
9d8e182227 feat(ui): get all tabs working w/ new layout 2025-06-24 05:41:18 +10:00
psychedelicious
49420d3449 fix(ui): unnecessary dependency on tab selection in
useCanvasDeleteLayerHotkey
2025-06-24 05:41:18 +10:00
psychedelicious
7e04454106 fix(ui): inverted logic for resume queue button 2025-06-24 05:41:18 +10:00
psychedelicious
78fa526312 feat(ui): get layouts working 2025-06-24 05:41:18 +10:00
psychedelicious
8d0541b06e feat(ui): canvas launchpad 2025-06-24 05:41:18 +10:00
psychedelicious
f5b093b980 wip 2025-06-24 05:41:18 +10:00
psychedelicious
906b2f852c fix(ui): wonky stage sizing on first visibility 2025-06-24 05:41:18 +10:00
psychedelicious
83d41c8bd1 wip 2025-06-24 05:41:18 +10:00
psychedelicious
afb318fd76 feat(ui): port UI slice to zod 2025-06-24 05:41:18 +10:00
psychedelicious
00410d1376 fix(ui): only show weight for IP adapters 2025-06-24 05:41:18 +10:00
psychedelicious
1662063152 feat(ui): represent IP adapter weight in ref image thumbnail 2025-06-24 05:41:18 +10:00
psychedelicious
376e5836b8 fix(ui): overflow on ref image model 2025-06-24 05:41:17 +10:00
psychedelicious
1a79109998 feat(ui): ref images feel more like buttons 2025-06-24 05:41:17 +10:00
psychedelicious
d0ff7256c9 feat(ui): switch tab on drag over tab button 2025-06-24 05:41:17 +10:00
psychedelicious
87ddaa602d feat(ui): tweak splash screen layout 2025-06-24 05:41:17 +10:00
psychedelicious
32f268af20 chore(ui): lint 2025-06-24 05:41:17 +10:00
psychedelicious
006c90127b feat(ui): rework simple session initial state 2025-06-24 05:41:17 +10:00
psychedelicious
ff21055cfb fix(ui): invoke button tooltip on generate tab 2025-06-24 05:41:17 +10:00
psychedelicious
92d16ffa96 fix(ui): progress image fixes 2025-06-24 05:41:17 +10:00
psychedelicious
e8a64ac766 feat(ui): make autoswitch on/off
When the invocation cache is used, we might skip all progress images. This can prevent auto-switch-on-first-progress from working, as we don't get any of those events.

It's much easier to only support auto-switch on complete.
2025-06-24 05:41:17 +10:00
psychedelicious
fd42db0b1b feat(ui): refine ref images UI 2025-06-24 05:41:17 +10:00
psychedelicious
5ad3f611b6 feat(ui): toggleable negative prompt 2025-06-24 05:41:17 +10:00
psychedelicious
6046ac2f75 fix(ui): remove old isSelected from refImageAdded call 2025-06-24 05:41:17 +10:00
psychedelicious
933a45e0fe chore: bump version to v6.0.0a2 2025-06-24 05:41:16 +10:00
psychedelicious
177f879bb3 fix(ui): update queue item preview images on init of queue items context 2025-06-24 05:41:16 +10:00
psychedelicious
fe09ff3501 fix(ui): hack to close chakra tooltips on drag 2025-06-24 05:41:16 +10:00
psychedelicious
5313aac7f8 tweak(ui): ref image header 2025-06-24 05:41:16 +10:00
psychedelicious
8ece17f39f experiment(ui): add generate tab 2025-06-24 05:41:16 +10:00
psychedelicious
d18c4cb6e5 refactor(ui): ref images (WIP) 2025-06-24 05:41:16 +10:00
psychedelicious
1a8bf3cac8 refactor(ui): ref images (WIP) 2025-06-24 05:41:16 +10:00
psychedelicious
194e42ac99 refactor(ui): refImage.ipAdapter -> refImage.config 2025-06-24 05:41:16 +10:00
psychedelicious
25e002e4d6 feat(ui): split out ref images into own slice (WIP) 2025-06-24 05:41:16 +10:00
psychedelicious
2206bc543e feat(ui): simple session initial state cards are buttons 2025-06-24 05:41:16 +10:00
psychedelicious
7547c17758 chore(ui): dpdm 2025-06-24 05:41:16 +10:00
psychedelicious
3d58da6d18 refactor(ui): async modal pattern; use for deleting images
This was needed for a canvas flow change which is currently paused, but the new API is much much nicer to use, so I am keeping it.
2025-06-24 05:41:16 +10:00
psychedelicious
2b06f252be fix(ui): use imageDTO in staging area 2025-06-24 05:41:15 +10:00
psychedelicious
265f74e642 fix(ui): wait until last queue item deleted before flagging canvas session finished 2025-06-24 05:41:15 +10:00
psychedelicious
c9ee9d9c27 feat(ui): store output image DTO in session context instead of just the name 2025-06-24 05:41:15 +10:00
psychedelicious
72ab5268c7 feat(ui): add AppGetState type 2025-06-24 05:41:15 +10:00
psychedelicious
237ae28373 feat(ui): close viewer on escape 2025-06-24 05:41:15 +10:00
psychedelicious
f6e7a1bde7 fix(ui): switch only on first progress image 2025-06-24 05:41:15 +10:00
psychedelicious
4fb40fcf86 feat(ui): add on first progress autoswitch mode 2025-06-24 05:41:15 +10:00
psychedelicious
2a10c00117 feat(ui): move canvas-specific staging subscriptions to CanvasStagingAreaModule 2025-06-24 05:41:15 +10:00
psychedelicious
46e6334e27 chore(ui): lint 2025-06-24 05:41:15 +10:00
psychedelicious
e0fbb9b916 feat(ui): make main panel styling and title consistent 2025-06-24 05:41:15 +10:00
psychedelicious
607b292a16 feat(ui): add startover button to canvas toolbar 2025-06-24 05:41:15 +10:00
psychedelicious
a05b8cf536 feat(ui): fiddle w/ staging area header 2025-06-24 05:41:15 +10:00
psychedelicious
2145dd217e feat(ui): remove technical progress message from full preview 2025-06-24 05:41:15 +10:00
psychedelicious
7d58b37d68 feat(ui): simple session initial state 2025-06-24 05:41:14 +10:00
psychedelicious
c85f3f88db feat(ui): remove vary and edit as control buttons 2025-06-24 05:41:14 +10:00
psychedelicious
b7751a85c2 refactor(ui): migrate from canceling queue items to deleteing, make queue hook APIs consistent 2025-06-24 05:41:14 +10:00
psychedelicious
921dcd81b8 fix(ui): mini preview bg color 2025-06-24 05:41:14 +10:00
psychedelicious
01acac8c4e fix(ui): hide layers when not on canvas tab 2025-06-24 05:41:14 +10:00
psychedelicious
3f7a9b7d82 build(ui): temporarily ignore all knip issues 2025-06-24 05:41:14 +10:00
psychedelicious
21f8263ddf feat(ui): finish generation when discarding last item 2025-06-24 05:41:14 +10:00
psychedelicious
c2db93669c feat(ui): when discarding last item, select new last instead of first 2025-06-24 05:41:14 +10:00
psychedelicious
37c0961597 feat(ui): tweak staging image display 2025-06-24 05:41:14 +10:00
psychedelicious
b13be4891a feat(ui): add staging area toolbar to simple session 2025-06-24 05:41:14 +10:00
psychedelicious
5cad4ed06d fix(ui): ensure canvas tool modules are destroyed 2025-06-24 05:41:14 +10:00
psychedelicious
c8c8a79c07 fix(ui): reset layers when changing session type 2025-06-24 05:41:14 +10:00
psychedelicious
22ff5f71cc feat(ui): improved staging placeholders 2025-06-24 05:41:13 +10:00
psychedelicious
b2ee934e8c feat(ui): improved staging placeholders 2025-06-24 05:41:13 +10:00
psychedelicious
19f8bb4795 feat(ui): more staging fixes 2025-06-24 05:41:13 +10:00
psychedelicious
85e17aa36b feat(ui): update canvas session state handling for new staging strat 2025-06-24 05:41:13 +10:00
psychedelicious
88680a75c9 chore(ui): lint (partial cleanup) 2025-06-24 05:41:13 +10:00
psychedelicious
b038c79451 feat(ui): rough out canvas staging area 2025-06-24 05:41:13 +10:00
psychedelicious
088eea9a0e feat(app): support deleting queue items by id or destination 2025-06-24 05:41:13 +10:00
psychedelicious
c575eb14ca feat(ui): tweak canvas scroll to zoom feel 2025-06-24 05:41:13 +10:00
psychedelicious
ffb3dc2bcd docs(ui): add comment about auto-switch not being quite right yet 2025-06-24 05:41:13 +10:00
psychedelicious
b1b1d7c2a6 feat: canvas flow rework (wip) 2025-06-24 05:41:13 +10:00
psychedelicious
89b34cb225 feat(ui): prevent flicker of image action buttons 2025-06-24 05:41:13 +10:00
psychedelicious
aa4b0d6705 feat(ui): move socket events handling into ctx component 2025-06-24 05:41:13 +10:00
psychedelicious
b97ad8518c feat(ui): modularize all staging area logic so it can be shared w/ canvas more easily 2025-06-24 05:41:13 +10:00
psychedelicious
eda3cc2306 perf(ui): queue actions menu is lazy 2025-06-24 05:41:12 +10:00
psychedelicious
ec564725b1 fix(ui): cursor on staging area preview image 2025-06-24 05:41:12 +10:00
psychedelicious
c2fc3c6328 feat(ui): remove clear queue ui components 2025-06-24 05:41:12 +10:00
psychedelicious
fdd2051257 feat(app): do not prune queue on startup
With the new canvas design, this will result in loss of staging area images.
2025-06-24 05:41:12 +10:00
psychedelicious
4d7213baf7 tidy(ui): component organization 2025-06-24 05:41:12 +10:00
psychedelicious
533018b14e fix(ui): prevent drag of progress images 2025-06-24 05:41:12 +10:00
psychedelicious
d2b486cd8e feat: canvas flow rework (wip) 2025-06-24 05:41:12 +10:00
psychedelicious
e05b3cdd8a feat: canvas flow rework (wip) 2025-06-24 05:41:12 +10:00
psychedelicious
6c98b4b38d chore(ui): typegen 2025-06-24 05:41:12 +10:00
psychedelicious
a10f5efd16 feat(api): remove status from list all queue items query 2025-06-24 05:41:12 +10:00
psychedelicious
6354d363c5 tidy(ui): app layout components 2025-06-24 05:41:12 +10:00
psychedelicious
161559a1fa feat: canvas flow rework (wip) 2025-06-24 05:41:11 +10:00
psychedelicious
c0c1649436 feat: canvas flow rework (wip) 2025-06-24 05:41:11 +10:00
psychedelicious
10105a7e4e feat: canvas flow rework (wip) 2025-06-24 05:41:11 +10:00
psychedelicious
ffb6e87d50 fix(ui): unstable selector results in lora drop down 2025-06-24 05:41:11 +10:00
psychedelicious
d667bb1741 feat: canvas flow rework (wip) 2025-06-24 05:41:11 +10:00
psychedelicious
52f8cf6840 feat: canvas flow rework (wip) 2025-06-24 05:41:11 +10:00
psychedelicious
1a3b3dad8a wip progress events 2025-06-24 05:41:11 +10:00
psychedelicious
4957dd8fa2 refactor(ui): canvas flow (wip) 2025-06-24 05:41:11 +10:00
psychedelicious
30e22a0728 fix(ui): ref goes undefined in GalleryImage
This appears to be a bug in Chakra UI v2 - use of a fallback component makes the ref passed to an image end up undefined. Had to remove the skeleton loader fallback component.
2025-06-24 05:41:11 +10:00
psychedelicious
2b297d4d37 fix(ui): merge refs when forwardingin DndImage 2025-06-24 05:41:11 +10:00
psychedelicious
5cbac72167 fix(ui): remove unused sessionId field from type 2025-06-24 05:41:10 +10:00
psychedelicious
3b3672e4ae fix(ui): ensure all args are passed to handler when creating new canvas from image 2025-06-24 05:41:10 +10:00
psychedelicious
e39ce1fee2 feat(ui): bookmark new inpaint masks 2025-06-24 05:41:10 +10:00
psychedelicious
6e0e394095 feat(ui): support bookmarking an entity when adding it 2025-06-24 05:41:10 +10:00
psychedelicious
3ec39c9b3f fix(ui): ensure images are added to gallery in simple sessions 2025-06-24 05:41:10 +10:00
psychedelicious
b19df9ddcf feat(ui): images always added to gallery in simple session 2025-06-24 05:41:10 +10:00
psychedelicious
0c34a49c58 wip 2025-06-24 05:41:10 +10:00
psychedelicious
a0721835d6 refactor(ui): canvas flow (wip) 2025-06-24 05:41:10 +10:00
psychedelicious
6a534d5b4f refactor(ui): canvas flow (wip) 2025-06-24 05:41:10 +10:00
psychedelicious
0f680e16b6 refactor(ui): canvas flow events (wip) 2025-06-24 05:41:10 +10:00
psychedelicious
83e82e25a6 refactor(ui): canvas flow (wip) 2025-06-24 05:41:09 +10:00
psychedelicious
b85736ccf3 refactor(ui): canvas flow (wip) 2025-06-24 05:41:09 +10:00
psychedelicious
2fb1d93038 refactor(ui): canvas flow (wip) 2025-06-24 05:41:09 +10:00
psychedelicious
05543b6073 refactor(ui): canvas flow (wip) 2025-06-24 05:41:09 +10:00
psychedelicious
95986e4aa0 fix(ui): circular import issue 2025-06-24 05:41:09 +10:00
psychedelicious
f7bf459721 refactor(ui): params state zodification 2025-06-24 05:41:09 +10:00
psychedelicious
b266ce78f5 refactor(ui): move params state to big file of canvas zod stuff 2025-06-24 05:41:09 +10:00
psychedelicious
fcaf216a3d refactor(ui): zod-ify params slice state 2025-06-24 05:41:09 +10:00
psychedelicious
12fdc934ee refactor(ui): org state in prep for new flow 2025-06-24 05:41:09 +10:00
psychedelicious
dee7b74c59 refactor(ui): image viewer & comparison convolutedness 2025-06-24 05:41:09 +10:00
psychedelicious
1411395b06 feat(ui): default canvas tool is move 2025-06-24 05:41:08 +10:00
psychedelicious
e3a56c8b81 chore(ui): bump @reduxjs/toolkit to latest 2025-06-24 05:41:08 +10:00
psychedelicious
eb41fd1ca6 feat(ui): viewer is a modal (wip) 2025-06-24 05:41:08 +10:00
Kent Keirsey
6a78739076 Change save button to Invoke Blue 2025-06-20 15:07:40 +10:00
psychedelicious
0794eb43e7 fix(nodes): ensure each invocation overrides _original_model_fields with own field data 2025-06-20 15:03:55 +10:00
91 changed files with 2539 additions and 887 deletions

View File

@@ -1,21 +1,12 @@
from fastapi import Body, HTTPException
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult
board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
class AddImagesToBoardResult(BaseModel):
board_id: str = Field(description="The id of the board the images were added to")
added_image_names: list[str] = Field(description="The image names that were added to the board")
class RemoveImagesFromBoardResult(BaseModel):
removed_image_names: list[str] = Field(description="The image names that were removed from their board")
@board_images_router.post(
"/",
operation_id="add_image_to_board",
@@ -23,17 +14,26 @@ class RemoveImagesFromBoardResult(BaseModel):
201: {"description": "The image was added to a board successfully"},
},
status_code=201,
response_model=AddImagesToBoardResult,
)
async def add_image_to_board(
board_id: str = Body(description="The id of the board to add to"),
image_name: str = Body(description="The name of the image to add"),
):
) -> AddImagesToBoardResult:
"""Creates a board_image"""
try:
result = ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
added_images: set[str] = set()
affected_boards: set[str] = set()
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name)
added_images.add(image_name)
affected_boards.add(board_id)
affected_boards.add(old_board_id)
return AddImagesToBoardResult(
added_images=list(added_images),
affected_boards=list(affected_boards),
)
return result
except Exception:
raise HTTPException(status_code=500, detail="Failed to add image to board")
@@ -45,14 +45,25 @@ async def add_image_to_board(
201: {"description": "The image was removed from the board successfully"},
},
status_code=201,
response_model=RemoveImagesFromBoardResult,
)
async def remove_image_from_board(
image_name: str = Body(description="The name of the image to remove", embed=True),
):
) -> RemoveImagesFromBoardResult:
"""Removes an image from its board, if it had one"""
try:
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
return result
removed_images: set[str] = set()
affected_boards: set[str] = set()
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
removed_images.add(image_name)
affected_boards.add("none")
affected_boards.add(old_board_id)
return RemoveImagesFromBoardResult(
removed_images=list(removed_images),
affected_boards=list(affected_boards),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove image from board")
@@ -72,16 +83,25 @@ async def add_images_to_board(
) -> AddImagesToBoardResult:
"""Adds a list of images to a board"""
try:
added_image_names: list[str] = []
added_images: set[str] = set()
affected_boards: set[str] = set()
for image_name in image_names:
try:
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
board_id=board_id,
image_name=image_name,
)
added_image_names.append(image_name)
added_images.add(image_name)
affected_boards.add(board_id)
affected_boards.add(old_board_id)
except Exception:
pass
return AddImagesToBoardResult(board_id=board_id, added_image_names=added_image_names)
return AddImagesToBoardResult(
added_images=list(added_images),
affected_boards=list(affected_boards),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to add images to board")
@@ -100,13 +120,20 @@ async def remove_images_from_board(
) -> RemoveImagesFromBoardResult:
"""Removes a list of images from their board, if they had one"""
try:
removed_image_names: list[str] = []
removed_images: set[str] = set()
affected_boards: set[str] = set()
for image_name in image_names:
try:
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
removed_image_names.append(image_name)
removed_images.add(image_name)
affected_boards.add("none")
affected_boards.add(old_board_id)
except Exception:
pass
return RemoveImagesFromBoardResult(removed_image_names=removed_image_names)
return RemoveImagesFromBoardResult(
removed_images=list(removed_images),
affected_boards=list(affected_boards),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove images from board")

View File

@@ -1,7 +1,7 @@
import io
import json
import traceback
from typing import ClassVar, Optional
from typing import ClassVar, Literal, Optional
from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
@@ -14,10 +14,17 @@ from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_i
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageCollectionCounts,
ImageRecordChanges,
ResourceOrigin,
)
from invokeai.app.services.images.images_common import ImageDTO, ImageUrlsDTO
from invokeai.app.services.images.images_common import (
DeleteImagesResult,
ImageDTO,
ImageUrlsDTO,
StarredImagesResult,
UnstarredImagesResult,
)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.util.controlnet_utils import heuristic_resize_fast
@@ -153,18 +160,30 @@ async def create_image_upload_entry(
raise HTTPException(status_code=501, detail="Not implemented")
@images_router.delete("/i/{image_name}", operation_id="delete_image")
@images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult)
async def delete_image(
image_name: str = Path(description="The name of the image to delete"),
) -> None:
) -> DeleteImagesResult:
"""Deletes an image"""
deleted_images: set[str] = set()
affected_boards: set[str] = set()
try:
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
board_id = image_dto.board_id or "none"
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.add(image_name)
affected_boards.add(board_id)
except Exception:
# TODO: Does this need any exception handling at all?
pass
return DeleteImagesResult(
deleted_images=list(deleted_images),
affected_boards=list(affected_boards),
)
@images_router.delete("/intermediates", operation_id="clear_intermediates")
async def clear_intermediates() -> int:
@@ -376,31 +395,32 @@ async def list_image_dtos(
return image_dtos
class DeleteImagesFromListResult(BaseModel):
deleted_images: list[str]
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesFromListResult)
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult)
async def delete_images_from_list(
image_names: list[str] = Body(description="The list of names of images to delete", embed=True),
) -> DeleteImagesFromListResult:
) -> DeleteImagesResult:
try:
deleted_images: list[str] = []
deleted_images: set[str] = set()
affected_boards: set[str] = set()
for image_name in image_names:
try:
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
board_id = image_dto.board_id or "none"
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.append(image_name)
deleted_images.add(image_name)
affected_boards.add(board_id)
except Exception:
pass
return DeleteImagesFromListResult(deleted_images=deleted_images)
return DeleteImagesResult(
deleted_images=list(deleted_images),
affected_boards=list(affected_boards),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete images")
@images_router.delete(
"/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesFromListResult
)
async def delete_uncategorized_images() -> DeleteImagesFromListResult:
@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult)
async def delete_uncategorized_images() -> DeleteImagesResult:
"""Deletes all images that are uncategorized"""
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
@@ -408,14 +428,19 @@ async def delete_uncategorized_images() -> DeleteImagesFromListResult:
)
try:
deleted_images: list[str] = []
deleted_images: set[str] = set()
affected_boards: set[str] = set()
for image_name in image_names:
try:
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.append(image_name)
deleted_images.add(image_name)
affected_boards.add("none")
except Exception:
pass
return DeleteImagesFromListResult(deleted_images=deleted_images)
return DeleteImagesResult(
deleted_images=list(deleted_images),
affected_boards=list(affected_boards),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete images")
@@ -424,36 +449,50 @@ class ImagesUpdatedFromListResult(BaseModel):
updated_image_names: list[str] = Field(description="The image names that were updated")
@images_router.post("/star", operation_id="star_images_in_list", response_model=ImagesUpdatedFromListResult)
@images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult)
async def star_images_in_list(
image_names: list[str] = Body(description="The list of names of images to star", embed=True),
) -> ImagesUpdatedFromListResult:
) -> StarredImagesResult:
try:
updated_image_names: list[str] = []
starred_images: set[str] = set()
affected_boards: set[str] = set()
for image_name in image_names:
try:
ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=True))
updated_image_names.append(image_name)
updated_image_dto = ApiDependencies.invoker.services.images.update(
image_name, changes=ImageRecordChanges(starred=True)
)
starred_images.add(image_name)
affected_boards.add(updated_image_dto.board_id or "none")
except Exception:
pass
return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
return StarredImagesResult(
starred_images=list(starred_images),
affected_boards=list(affected_boards),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to star images")
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=ImagesUpdatedFromListResult)
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult)
async def unstar_images_in_list(
image_names: list[str] = Body(description="The list of names of images to unstar", embed=True),
) -> ImagesUpdatedFromListResult:
) -> UnstarredImagesResult:
try:
updated_image_names: list[str] = []
unstarred_images: set[str] = set()
affected_boards: set[str] = set()
for image_name in image_names:
try:
ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=False))
updated_image_names.append(image_name)
updated_image_dto = ApiDependencies.invoker.services.images.update(
image_name, changes=ImageRecordChanges(starred=False)
)
unstarred_images.add(image_name)
affected_boards.add(updated_image_dto.board_id or "none")
except Exception:
pass
return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
return UnstarredImagesResult(
unstarred_images=list(unstarred_images),
affected_boards=list(affected_boards),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to unstar images")
@@ -524,3 +563,92 @@ async def get_bulk_download_item(
return response
except Exception:
raise HTTPException(status_code=404)
@images_router.get(
"/collections/counts", operation_id="get_image_collection_counts", response_model=ImageCollectionCounts
)
async def get_image_collection_counts(
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to count."),
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
is_intermediate: Optional[bool] = Query(default=None, description="Whether to include intermediate images."),
board_id: Optional[str] = Query(
default=None,
description="The board id to filter by. Use 'none' to find images without a board.",
),
search_term: Optional[str] = Query(default=None, description="The term to search for"),
) -> ImageCollectionCounts:
"""Gets counts for starred and unstarred image collections"""
try:
return ApiDependencies.invoker.services.images.get_collection_counts(
image_origin=image_origin,
categories=categories,
is_intermediate=is_intermediate,
board_id=board_id,
search_term=search_term,
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to get collection counts")
@images_router.get("/collections/{collection}", operation_id="get_image_collection")
async def get_image_collection(
collection: Literal["starred", "unstarred"] = Path(..., description="The collection to retrieve from"),
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
board_id: Optional[str] = Query(
default=None,
description="The board id to filter by. Use 'none' to find images without a board.",
),
offset: int = Query(default=0, description="The offset within the collection"),
limit: int = Query(default=50, description="The number of images to return"),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
search_term: Optional[str] = Query(default=None, description="The term to search for"),
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets images from a specific collection (starred or unstarred)"""
try:
image_dtos = ApiDependencies.invoker.services.images.get_collection_images(
collection=collection,
offset=offset,
limit=limit,
order_dir=order_dir,
image_origin=image_origin,
categories=categories,
is_intermediate=is_intermediate,
board_id=board_id,
search_term=search_term,
)
return image_dtos
except Exception:
raise HTTPException(status_code=500, detail="Failed to get collection images")
@images_router.get("/names", operation_id="get_image_names")
async def get_image_names(
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
board_id: Optional[str] = Query(
default=None,
description="The board id to filter by. Use 'none' to find images without a board.",
),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
search_term: Optional[str] = Query(default=None, description="The term to search for"),
) -> list[str]:
"""Gets ordered list of all image names (starred first, then unstarred)"""
try:
image_names = ApiDependencies.invoker.services.images.get_image_names(
order_dir=order_dir,
image_origin=image_origin,
categories=categories,
is_intermediate=is_intermediate,
board_id=board_id,
search_term=search_term,
)
return image_names
except Exception:
raise HTTPException(status_code=500, detail="Failed to get image names")

View File

@@ -1,10 +1,11 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional
from typing import Literal, Optional
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageCollectionCounts,
ImageRecord,
ImageRecordChanges,
ResourceOrigin,
@@ -97,3 +98,44 @@ class ImageRecordStorageBase(ABC):
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
"""Gets the most recent image for a board."""
pass
@abstractmethod
def get_collection_counts(
self,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> ImageCollectionCounts:
"""Gets counts for starred and unstarred image collections."""
pass
@abstractmethod
def get_collection_images(
self,
collection: Literal["starred", "unstarred"],
offset: int = 0,
limit: int = 10,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageRecord]:
"""Gets images from a specific collection (starred or unstarred)."""
pass
@abstractmethod
def get_image_names(
self,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> list[str]:
"""Gets ordered list of all image names (starred first, then unstarred)."""
pass

View File

@@ -3,7 +3,7 @@ import datetime
from enum import Enum
from typing import Optional, Union
from pydantic import Field, StrictBool, StrictStr
from pydantic import BaseModel, Field, StrictBool, StrictStr
from invokeai.app.util.metaenum import MetaEnum
from invokeai.app.util.misc import get_iso_timestamp
@@ -207,3 +207,8 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord:
starred=starred,
has_workflow=has_workflow,
)
class ImageCollectionCounts(BaseModel):
starred_count: int = Field(description="The number of starred images in the collection.")
unstarred_count: int = Field(description="The number of unstarred images in the collection.")

View File

@@ -1,12 +1,13 @@
import sqlite3
from datetime import datetime
from typing import Optional, Union, cast
from typing import Literal, Optional, Union, cast
from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator
from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase
from invokeai.app.services.image_records.image_records_common import (
IMAGE_DTO_COLS,
ImageCategory,
ImageCollectionCounts,
ImageRecord,
ImageRecordChanges,
ImageRecordDeleteException,
@@ -386,3 +387,253 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
return None
return deserialize_image_record(dict(result))
def get_collection_counts(
self,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> ImageCollectionCounts:
cursor = self._conn.cursor()
# Build the base query conditions (same as get_many)
base_query = """--sql
FROM images
LEFT JOIN board_images ON board_images.image_name = images.image_name
WHERE 1=1
"""
query_conditions = ""
query_params: list[Union[int, str, bool]] = []
if image_origin is not None:
query_conditions += """--sql
AND images.image_origin = ?
"""
query_params.append(image_origin.value)
if categories is not None:
category_strings = [c.value for c in set(categories)]
placeholders = ",".join("?" * len(category_strings))
query_conditions += f"""--sql
AND images.image_category IN ( {placeholders} )
"""
for c in category_strings:
query_params.append(c)
if is_intermediate is not None:
query_conditions += """--sql
AND images.is_intermediate = ?
"""
query_params.append(is_intermediate)
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
elif board_id is not None:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
if search_term:
query_conditions += """--sql
AND (
images.metadata LIKE ?
OR images.created_at LIKE ?
)
"""
query_params.append(f"%{search_term.lower()}%")
query_params.append(f"%{search_term.lower()}%")
# Get starred count
starred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = TRUE;"
cursor.execute(starred_query, query_params)
starred_count = cast(int, cursor.fetchone()[0])
# Get unstarred count
unstarred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = FALSE;"
cursor.execute(unstarred_query, query_params)
unstarred_count = cast(int, cursor.fetchone()[0])
return ImageCollectionCounts(starred_count=starred_count, unstarred_count=unstarred_count)
def get_collection_images(
self,
collection: Literal["starred", "unstarred"],
offset: int = 0,
limit: int = 10,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageRecord]:
cursor = self._conn.cursor()
# Base queries
count_query = """--sql
SELECT COUNT(*)
FROM images
LEFT JOIN board_images ON board_images.image_name = images.image_name
WHERE 1=1
"""
images_query = f"""--sql
SELECT {IMAGE_DTO_COLS}
FROM images
LEFT JOIN board_images ON board_images.image_name = images.image_name
WHERE 1=1
"""
query_conditions = ""
query_params: list[Union[int, str, bool]] = []
# Add starred/unstarred filter
is_starred = collection == "starred"
query_conditions += """--sql
AND images.starred = ?
"""
query_params.append(is_starred)
if image_origin is not None:
query_conditions += """--sql
AND images.image_origin = ?
"""
query_params.append(image_origin.value)
if categories is not None:
category_strings = [c.value for c in set(categories)]
placeholders = ",".join("?" * len(category_strings))
query_conditions += f"""--sql
AND images.image_category IN ( {placeholders} )
"""
for c in category_strings:
query_params.append(c)
if is_intermediate is not None:
query_conditions += """--sql
AND images.is_intermediate = ?
"""
query_params.append(is_intermediate)
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
elif board_id is not None:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
if search_term:
query_conditions += """--sql
AND (
images.metadata LIKE ?
OR images.created_at LIKE ?
)
"""
query_params.append(f"%{search_term.lower()}%")
query_params.append(f"%{search_term.lower()}%")
# Add ordering and pagination
query_pagination = f"""--sql
ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ?
"""
# Execute images query
images_query += query_conditions + query_pagination + ";"
images_params = query_params.copy()
images_params.extend([limit, offset])
cursor.execute(images_query, images_params)
result = cast(list[sqlite3.Row], cursor.fetchall())
images = [deserialize_image_record(dict(r)) for r in result]
# Execute count query
count_query += query_conditions + ";"
cursor.execute(count_query, query_params)
count = cast(int, cursor.fetchone()[0])
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
def get_image_names(
self,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> list[str]:
cursor = self._conn.cursor()
# Base query to get image names in order (starred first, then unstarred)
query = """--sql
SELECT images.image_name
FROM images
LEFT JOIN board_images ON board_images.image_name = images.image_name
WHERE 1=1
"""
query_conditions = ""
query_params: list[Union[int, str, bool]] = []
if image_origin is not None:
query_conditions += """--sql
AND images.image_origin = ?
"""
query_params.append(image_origin.value)
if categories is not None:
category_strings = [c.value for c in set(categories)]
placeholders = ",".join("?" * len(category_strings))
query_conditions += f"""--sql
AND images.image_category IN ( {placeholders} )
"""
for c in category_strings:
query_params.append(c)
if is_intermediate is not None:
query_conditions += """--sql
AND images.is_intermediate = ?
"""
query_params.append(is_intermediate)
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
elif board_id is not None:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
if search_term:
query_conditions += """--sql
AND (
images.metadata LIKE ?
OR images.created_at LIKE ?
)
"""
query_params.append(f"%{search_term.lower()}%")
query_params.append(f"%{search_term.lower()}%")
# Order by starred first, then by created_at
query += (
query_conditions
+ f"""--sql
ORDER BY images.starred DESC, images.created_at {order_dir.value}
"""
)
cursor.execute(query, query_params)
result = cast(list[sqlite3.Row], cursor.fetchall())
return [row[0] for row in result]

View File

@@ -1,11 +1,12 @@
from abc import ABC, abstractmethod
from typing import Callable, Optional
from typing import Callable, Literal, Optional
from PIL.Image import Image as PILImageType
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageCollectionCounts,
ImageRecord,
ImageRecordChanges,
ResourceOrigin,
@@ -125,7 +126,7 @@ class ImageServiceABC(ABC):
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a paginated list of image DTOs."""
"""Gets a paginated list of image DTOs with starred images first when starred_first=True."""
pass
@abstractmethod
@@ -147,3 +148,44 @@ class ImageServiceABC(ABC):
def delete_images_on_board(self, board_id: str):
"""Deletes all images on a board."""
pass
@abstractmethod
def get_collection_counts(
self,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> ImageCollectionCounts:
"""Gets counts for starred and unstarred image collections."""
pass
@abstractmethod
def get_collection_images(
self,
collection: Literal["starred", "unstarred"],
offset: int = 0,
limit: int = 10,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets images from a specific collection (starred or unstarred)."""
pass
@abstractmethod
def get_image_names(
self,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> list[str]:
"""Gets ordered list of all image names (starred first, then unstarred)."""
pass

View File

@@ -1,6 +1,6 @@
from typing import Optional
from pydantic import Field
from pydantic import BaseModel, Field
from invokeai.app.services.image_records.image_records_common import ImageRecord
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
@@ -39,3 +39,27 @@ def image_record_to_dto(
thumbnail_url=thumbnail_url,
board_id=board_id,
)
class ResultWithAffectedBoards(BaseModel):
affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation")
class DeleteImagesResult(ResultWithAffectedBoards):
deleted_images: list[str] = Field(description="The names of the images that were deleted")
class StarredImagesResult(ResultWithAffectedBoards):
starred_images: list[str] = Field(description="The names of the images that were starred")
class UnstarredImagesResult(ResultWithAffectedBoards):
unstarred_images: list[str] = Field(description="The names of the images that were unstarred")
class AddImagesToBoardResult(ResultWithAffectedBoards):
added_images: list[str] = Field(description="The image names that were added to the board")
class RemoveImagesFromBoardResult(ResultWithAffectedBoards):
removed_images: list[str] = Field(description="The image names that were removed from their board")

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from PIL.Image import Image as PILImageType
@@ -10,6 +10,7 @@ from invokeai.app.services.image_files.image_files_common import (
)
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageCollectionCounts,
ImageRecord,
ImageRecordChanges,
ImageRecordDeleteException,
@@ -309,3 +310,90 @@ class ImageService(ImageServiceABC):
except Exception as e:
self.__invoker.services.logger.error("Problem getting intermediates count")
raise e
def get_collection_counts(
self,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> ImageCollectionCounts:
try:
return self.__invoker.services.image_records.get_collection_counts(
image_origin=image_origin,
categories=categories,
is_intermediate=is_intermediate,
board_id=board_id,
search_term=search_term,
)
except Exception as e:
self.__invoker.services.logger.error("Problem getting collection counts")
raise e
def get_collection_images(
self,
collection: Literal["starred", "unstarred"],
offset: int = 0,
limit: int = 10,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageDTO]:
try:
results = self.__invoker.services.image_records.get_collection_images(
collection=collection,
offset=offset,
limit=limit,
order_dir=order_dir,
image_origin=image_origin,
categories=categories,
is_intermediate=is_intermediate,
board_id=board_id,
search_term=search_term,
)
image_dtos = [
image_record_to_dto(
image_record=r,
image_url=self.__invoker.services.urls.get_image_url(r.image_name),
thumbnail_url=self.__invoker.services.urls.get_image_url(r.image_name, True),
board_id=self.__invoker.services.board_image_records.get_board_for_image(r.image_name),
)
for r in results.items
]
return OffsetPaginatedResults[ImageDTO](
items=image_dtos,
offset=results.offset,
limit=results.limit,
total=results.total,
)
except Exception as e:
self.__invoker.services.logger.error("Problem getting collection images")
raise e
def get_image_names(
self,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> list[str]:
try:
return self.__invoker.services.image_records.get_image_names(
order_dir=order_dir,
image_origin=image_origin,
categories=categories,
is_intermediate=is_intermediate,
board_id=board_id,
search_term=search_term,
)
except Exception as e:
self.__invoker.services.logger.error("Problem getting image names")
raise e

View File

@@ -12,11 +12,13 @@ module.exports = {
// TODO: ENABLE THIS RULE BEFORE v6.0.0
// 'i18next/no-literal-string': 'error',
// https://eslint.org/docs/latest/rules/no-console
'no-console': 'error',
'no-console': 'warn',
// https://eslint.org/docs/latest/rules/no-promise-executor-return
'no-promise-executor-return': 'error',
// https://eslint.org/docs/latest/rules/require-await
'require-await': 'error',
// TODO: ENABLE THIS RULE BEFORE v6.0.0
'react/display-name': 'off',
'no-restricted-properties': [
'error',
{

View File

@@ -7,11 +7,13 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const GlobalImageHotkeys = memo(() => {
useAssertSingleton('GlobalImageHotkeys');
const imageDTO = useAppSelector(selectLastSelectedImage);
const imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
if (!imageDTO) {
return null;

View File

@@ -1,6 +1,7 @@
import '@fontsource-variable/inter';
import 'overlayscrollbars/overlayscrollbars.css';
import '@xyflow/react/dist/base.css';
import 'common/components/OverlayScrollbars/overlayscrollbars.css';
import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
import type { ReactNode } from 'react';

View File

@@ -15,8 +15,6 @@ import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMi
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred';
import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred';
import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded';
import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
@@ -47,10 +45,6 @@ addImageUploadedFulfilledListener(startAppListening);
// Image deleted
addDeleteBoardAndImagesFulfilledListener(startAppListening);
// Image starred
addImagesStarredListener(startAppListening);
addImagesUnstarredListener(startAppListening);
// Gallery
addGalleryImageClickedListener(startAppListening);
addGalleryOffsetChangedListener(startAppListening);

View File

@@ -25,7 +25,7 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis
matcher: matchAnyBoardDeleted,
effect: (action, { dispatch, getState }) => {
const state = getState();
const deletedBoardId = action.meta.arg.originalArgs;
const deletedBoardId = action.meta.arg.originalArgs.board_id;
const { autoAddBoardId, selectedBoardId } = state.gallery;
// If the deleted board was currently selected, we should reset the selected board to uncategorized

View File

@@ -30,9 +30,9 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
const selectedImage = boardImagesData.items.find(
(item) => item.image_name === action.payload.selectedImageName
);
dispatch(imageSelected(selectedImage || null));
dispatch(imageSelected(selectedImage?.image_name ?? null));
} else if (boardImagesData) {
dispatch(imageSelected(boardImagesData.items[0] || null));
dispatch(imageSelected(boardImagesData.items[0]?.image_name ?? null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));

View File

@@ -9,7 +9,7 @@ export const addEnsureImageIsSelectedListener = (startAppListening: AppStartList
effect: (action, { dispatch, getState }) => {
const selection = getState().gallery.selection;
if (selection.length === 0) {
dispatch(imageSelected(action.payload.items[0] ?? null));
dispatch(imageSelected(action.payload.items[0]?.image_name ?? null));
}
},
});

View File

@@ -1,12 +1,32 @@
import { createAction } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import type { RootState } from 'app/store/store';
import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import { uniq } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { ImageCategory, SQLiteDirection } from 'services/api/types';
// Type for image collection query arguments
type ImageCollectionQueryArgs = {
board_id?: string;
categories?: ImageCategory[];
search_term?: string;
order_dir?: SQLiteDirection;
is_intermediate: boolean;
};
/**
* Helper function to get cached image names list for selection operations
* Returns an ordered array of image names (starred first, then unstarred)
*/
const getCachedImageNames = (state: RootState, queryArgs: ImageCollectionQueryArgs): string[] => {
const queryResult = imagesApi.endpoints.getImageNames.select(queryArgs)(state);
return queryResult.data || [];
};
export const galleryImageClicked = createAction<{
imageDTO: ImageDTO;
imageName: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
@@ -28,45 +48,51 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
startAppListening({
actionCreator: galleryImageClicked,
effect: (action, { dispatch, getState }) => {
const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
const state = getState();
const queryArgs = selectListImagesQueryArgs(state);
const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state);
const queryArgs = selectImageCollectionQueryArgs(state);
if (!queryResult.data) {
// Should never happen if we have clicked a gallery image
// Get cached image names for selection operations
const imageNames = getCachedImageNames(state, queryArgs);
// If we don't have the image names cached, we can't perform selection operations
// This can happen if the user clicks on an image before the names are loaded
if (imageNames.length === 0) {
// For basic click without modifiers, we can still set selection
if (!shiftKey && !ctrlKey && !metaKey && !altKey) {
dispatch(selectionChanged([imageName]));
}
return;
}
const imageDTOs = queryResult.data.items;
const selection = state.gallery.selection;
if (altKey) {
if (state.gallery.imageToCompare?.image_name === imageDTO.image_name) {
if (state.gallery.imageToCompare === imageName) {
dispatch(imageToCompareChanged(null));
} else {
dispatch(imageToCompareChanged(imageDTO));
dispatch(imageToCompareChanged(imageName));
}
} else if (shiftKey) {
const rangeEndImageName = imageDTO.image_name;
const lastSelectedImage = selection[selection.length - 1]?.image_name;
const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage);
const currentClickedIndex = imageDTOs.findIndex((n) => n.image_name === rangeEndImageName);
const rangeEndImageName = imageName;
const lastSelectedImage = selection.at(-1);
const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage);
const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = imageDTOs.slice(start, end + 1);
dispatch(selectionChanged(selection.concat(imagesToSelect)));
const imagesToSelect = imageNames.slice(start, end + 1);
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect))));
}
} else if (ctrlKey || metaKey) {
if (selection.some((i) => i.image_name === imageDTO.image_name) && selection.length > 1) {
dispatch(selectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name)));
if (selection.some((n) => n === imageName) && selection.length > 1) {
dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName))));
} else {
dispatch(selectionChanged(selection.concat(imageDTO)));
dispatch(selectionChanged(uniq(selection.concat(imageName))));
}
} else {
dispatch(selectionChanged([imageDTO]));
dispatch(selectionChanged([imageName]));
}
},
});

View File

@@ -84,14 +84,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe
if (offset < prevOffset) {
// We've gone backwards
const lastImage = imageDTOs[imageDTOs.length - 1];
if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) {
dispatch(selectionChanged(lastImage ? [lastImage] : []));
if (!selection.some((selectedImage) => selectedImage === lastImage?.image_name)) {
dispatch(selectionChanged(lastImage ? [lastImage.image_name] : []));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) {
dispatch(selectionChanged(firstImage ? [firstImage] : []));
if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) {
dispatch(selectionChanged(firstImage ? [firstImage.image_name] : []));
}
}
return;
@@ -102,14 +102,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe
if (offset < prevOffset) {
// We've gone backwards
const lastImage = imageDTOs[imageDTOs.length - 1];
if (lastImage && imageToCompare?.image_name !== lastImage.image_name) {
dispatch(imageToCompareChanged(lastImage));
if (lastImage && imageToCompare !== lastImage.image_name) {
dispatch(imageToCompareChanged(lastImage.image_name));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (firstImage && imageToCompare?.image_name !== firstImage.image_name) {
dispatch(imageToCompareChanged(firstImage));
if (firstImage && imageToCompare !== firstImage.image_name) {
dispatch(imageToCompareChanged(firstImage.image_name));
}
}
return;

View File

@@ -8,16 +8,16 @@ export const addImageAddedToBoardFulfilledListener = (startAppListening: AppStar
startAppListening({
matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action) => {
const { board_id, imageDTO } = action.meta.arg.originalArgs;
log.debug({ board_id, imageDTO }, 'Image added to board');
const { board_id, image_name } = action.meta.arg.originalArgs;
log.debug({ board_id, image_name }, 'Image added to board');
},
});
startAppListening({
matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action) => {
const { board_id, imageDTO } = action.meta.arg.originalArgs;
log.debug({ board_id, imageDTO }, 'Problem adding image to board');
const { board_id, image_name } = action.meta.arg.originalArgs;
log.debug({ board_id, image_name }, 'Problem adding image to board');
},
});
};

View File

@@ -1,30 +0,0 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const addImagesStarredListener = (startAppListening: AppStartListening) => {
startAppListening({
matcher: imagesApi.endpoints.starImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const { updated_image_names: starredImages } = action.payload;
const state = getState();
const { selection } = state.gallery;
const updatedSelection: ImageDTO[] = [];
selection.forEach((selectedImageDTO) => {
if (starredImages.includes(selectedImageDTO.image_name)) {
updatedSelection.push({
...selectedImageDTO,
starred: true,
});
} else {
updatedSelection.push(selectedImageDTO);
}
});
dispatch(selectionChanged(updatedSelection));
},
});
};

View File

@@ -1,30 +0,0 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const addImagesUnstarredListener = (startAppListening: AppStartListening) => {
startAppListening({
matcher: imagesApi.endpoints.unstarImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const { updated_image_names: unstarredImages } = action.payload;
const state = getState();
const { selection } = state.gallery;
const updatedSelection: ImageDTO[] = [];
selection.forEach((selectedImageDTO) => {
if (unstarredImages.includes(selectedImageDTO.image_name)) {
updatedSelection.push({
...selectedImageDTO,
starred: false,
});
} else {
updatedSelection.push(selectedImageDTO);
}
});
dispatch(selectionChanged(updatedSelection));
},
});
};

View File

@@ -1,6 +1,6 @@
.os-scrollbar {
/* The size of the scrollbar */
--os-size: 9px;
--os-size: 8px;
/* The axis-perpedicular padding of the scrollbar (horizontal: padding-y, vertical: padding-x) */
/* --os-padding-perpendicular: 0; */
/* The axis padding of the scrollbar (horizontal: padding-x, vertical: padding-y) */
@@ -8,11 +8,11 @@
/* The border radius of the scrollbar track */
/* --os-track-border-radius: 0; */
/* The background of the scrollbar track */
/* --os-track-bg: rgba(0, 0, 0, 0.3); */
--os-track-bg: rgba(0, 0, 0, 0.5);
/* The :hover background of the scrollbar track */
/* --os-track-bg-hover: rgba(0, 0, 0, 0.3); */
--os-track-bg-hover: rgba(0, 0, 0, 0.5);
/* The :active background of the scrollbar track */
/* --os-track-bg-active: rgba(0, 0, 0, 0.3); */
--os-track-bg-active: rgba(0, 0, 0, 0.6);
/* The border of the scrollbar track */
/* --os-track-border: none; */
/* The :hover background of the scrollbar track */
@@ -22,11 +22,11 @@
/* The border radius of the scrollbar handle */
/* --os-handle-border-radius: 2px; */
/* The background of the scrollbar handle */
/* --os-handle-bg: var(--invokeai-colors-accentAlpha-500); */
--os-handle-bg: var(--invoke-colors-base-400);
/* The :hover background of the scrollbar handle */
/* --os-handle-bg-hover: var(--invokeai-colors-accentAlpha-700); */
--os-handle-bg-hover: var(--invoke-colors-base-300);
/* The :active background of the scrollbar handle */
/* --os-handle-bg-active: var(--invokeai-colors-accentAlpha-800); */
--os-handle-bg-active: var(--invoke-colors-base-250);
/* The border of the scrollbar handle */
/* --os-handle-border: none; */
/* The :hover border of the scrollbar handle */
@@ -34,23 +34,23 @@
/* The :active border of the scrollbar handle */
/* --os-handle-border-active: none; */
/* The min size of the scrollbar handle */
--os-handle-min-size: 50px;
--os-handle-min-size: 32px;
/* The max size of the scrollbar handle */
/* --os-handle-max-size: none; */
/* The axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
/* --os-handle-perpendicular-size: 100%; */
/* The :hover axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
/* --os-handle-perpendicular-size-hover: 100%; */
--os-handle-perpendicular-size-hover: 100%;
/* The :active axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */
/* --os-handle-perpendicular-size-active: 100%; */
/* Increases the interactive area of the scrollbar handle. */
/* --os-handle-interactive-area-offset: 0; */
--os-handle-interactive-area-offset: -1px;
}
.os-scrollbar-handle {
cursor: grab;
/* cursor: grab; */
}
.os-scrollbar-handle:active {
cursor: grabbing;
/* cursor: grabbing; */
}

View File

@@ -0,0 +1,28 @@
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

@@ -16,7 +16,7 @@ import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 's
const selectImagesToChange = createMemoizedSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.imagesToChange
(changeBoardModal) => changeBoardModal.image_names
);
const selectIsModalOpen = createSelector(
@@ -57,10 +57,10 @@ const ChangeBoardModal = () => {
}
if (selectedBoard === 'none') {
removeImagesFromBoard({ imageDTOs: imagesToChange });
removeImagesFromBoard({ image_names: imagesToChange });
} else {
addImagesToBoard({
imageDTOs: imagesToChange,
image_names: imagesToChange,
board_id: selectedBoard,
});
}

View File

@@ -2,5 +2,5 @@ import type { ChangeBoardModalState } from './types';
export const initialState: ChangeBoardModalState = {
isModalOpen: false,
imagesToChange: [],
image_names: [],
};

View File

@@ -1,7 +1,6 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import type { ImageDTO } from 'services/api/types';
import { initialState } from './initialState';
@@ -12,11 +11,11 @@ export const changeBoardModalSlice = createSlice({
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isModalOpen = action.payload;
},
imagesToChangeSelected: (state, action: PayloadAction<ImageDTO[]>) => {
state.imagesToChange = action.payload;
imagesToChangeSelected: (state, action: PayloadAction<string[]>) => {
state.image_names = action.payload;
},
changeBoardReset: (state) => {
state.imagesToChange = [];
state.image_names = [];
state.isModalOpen = false;
},
},

View File

@@ -1,6 +1,4 @@
import type { ImageDTO } from 'services/api/types';
export type ChangeBoardModalState = {
isModalOpen: boolean;
imagesToChange: ImageDTO[];
image_names: string[];
};

View File

@@ -11,6 +11,7 @@ import { QueueItemProgressImage } from 'features/controlLayers/components/Simple
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import { DndImage } from 'features/dnd/DndImage';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import type { S } from 'services/api/types';
@@ -46,12 +47,28 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
ctx.$selectedItemId.set(item.item_id);
}, [ctx.$selectedItemId, item.item_id]);
const onDoubleClick = useCallback(() => {
const autoSwitch = ctx.$autoSwitch.get();
if (autoSwitch !== 'off') {
ctx.$autoSwitch.set('off');
toast({
title: 'Auto-Switch Disabled',
});
}
}, [ctx.$autoSwitch]);
const onLoad = useCallback(() => {
ctx.onImageLoad(item.item_id);
}, [ctx, item.item_id]);
return (
<Flex id={getQueueItemElementId(item.item_id)} sx={sx} data-selected={isSelected} onClick={onClick}>
<Flex
id={getQueueItemElementId(item.item_id)}
sx={sx}
data-selected={isSelected}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail />}
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}

View File

@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppStore } from 'app/store/nanostores/store';
import { buildZodTypeGuard } from 'common/util/zodUtils';
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
import type { ProgressImage } from 'features/nodes/types/common';
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
@@ -13,6 +14,11 @@ import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO, S } from 'services/api/types';
import { $socket } from 'services/events/stores';
import { assert, objectEntries } from 'tsafe';
import { z } from 'zod';
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 = {
itemId: number;
@@ -91,7 +97,7 @@ type CanvasSessionContextValue = {
$selectedItem: Atom<S['SessionQueueItem'] | null>;
$selectedItemIndex: Atom<number | null>;
$selectedItemOutputImageDTO: Atom<ImageDTO | null>;
$autoSwitch: WritableAtom<boolean>;
$autoSwitch: WritableAtom<AutoSwitchMode>;
selectNext: () => void;
selectPrev: () => void;
selectFirst: () => void;
@@ -116,8 +122,17 @@ export const CanvasSessionContextProvider = memo(
const store = useAppStore();
const socket = useStore($socket);
/**
* Track the last completed item. Used to implement autoswitch.
*/
const $lastCompletedItemId = useState(() => atom<number | null>(null))[0];
/**
* Track the last started item. Used to implement autoswitch.
*/
const $lastStartedItemId = useState(() => atom<number | null>(null))[0];
/**
* Manually-synced atom containing queue items for the current session. This is populated from the RTK Query cache
* and kept in sync with it via a redux subscription.
@@ -127,7 +142,7 @@ export const CanvasSessionContextProvider = memo(
/**
* Whether auto-switch is enabled.
*/
const $autoSwitch = useState(() => atom(true))[0];
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
@@ -277,12 +292,12 @@ export const CanvasSessionContextProvider = memo(
imageLoaded: true,
});
}
if ($lastCompletedItemId.get() === itemId) {
if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') {
$selectedItemId.set(itemId);
$lastCompletedItemId.set(null);
}
},
[$lastCompletedItemId, $progressData, $selectedItemId]
[$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId]
);
// Set up socket listeners
@@ -305,6 +320,9 @@ export const CanvasSessionContextProvider = memo(
if (data.status === 'completed') {
$lastCompletedItemId.set(data.item_id);
}
if (data.status === 'in_progress') {
$lastStartedItemId.set(data.item_id);
}
};
socket.on('invocation_progress', onProgress);
@@ -314,7 +332,7 @@ export const CanvasSessionContextProvider = memo(
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId, session.id, socket]);
}, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
// Set up state subscriptions and effects
useEffect(() => {
@@ -333,29 +351,39 @@ export const CanvasSessionContextProvider = memo(
});
// Handle cases that could result in a nonexistent queue item being selected.
const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => {
// If there are no items, cannot have a selected item.
if (items.length === 0) {
$selectedItemId.set(null);
return;
}
// 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);
return;
}
// 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.
if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
if (prevIndex >= items.length) {
prevIndex = items.length - 1;
const unsubEnsureSelectedItemIdExists = effect(
[$items, $selectedItemId, $lastStartedItemId],
(items, selectedItemId, lastStartedItemId) => {
// If there are no items, cannot have a selected item.
if (items.length === 0) {
$selectedItemId.set(null);
return;
}
// 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);
return;
}
if (
$autoSwitch.get() === 'switch_on_start' &&
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
) {
$selectedItemId.set(lastStartedItemId);
$lastStartedItemId.set(null);
}
// 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.
if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
if (prevIndex >= items.length) {
prevIndex = items.length - 1;
}
const nextItem = items[prevIndex];
$selectedItemId.set(nextItem?.item_id ?? null);
return;
}
const nextItem = items[prevIndex];
$selectedItemId.set(nextItem?.item_id ?? null);
return;
}
});
);
// Clean up the progress data when a queue item is discarded.
const unsubCleanUpProgressData = $items.subscribe(async (items) => {
@@ -438,7 +466,7 @@ export const CanvasSessionContextProvider = memo(
if (lastLoadedItemId === null) {
return;
}
if ($autoSwitch.get()) {
if ($autoSwitch.get() === 'switch_on_finish') {
$selectedItemId.set(lastLoadedItemId);
}
$lastLoadedItemId.set(null);
@@ -461,7 +489,17 @@ export const CanvasSessionContextProvider = memo(
$progressData.set({});
$selectedItemId.set(null);
};
}, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]);
}, [
$autoSwitch,
$items,
$lastLoadedItemId,
$lastStartedItemId,
$progressData,
$selectedItemId,
selectQueueItems,
session.id,
store,
]);
const value = useMemo<CanvasSessionContextValue>(
() => ({

View File

@@ -1,19 +1,14 @@
import { isImageField } from 'features/nodes/types/common';
import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils';
import { round } from 'lodash-es';
import type { S } from 'services/api/types';
import { formatProgressMessage } from 'services/events/stores';
import { objectEntries } from 'tsafe';
export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) => {
if (!data) {
return 'Generating';
}
let message = data.message;
if (data.percentage) {
message += ` (${round(data.percentage * 100)}%)`;
}
return message;
return formatProgressMessage(data);
};
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';

View File

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

View File

@@ -19,6 +19,7 @@ export const CanvasToolbarSaveToGalleryButton = memo(() => {
onClick={shift ? saveBboxToGallery : saveCanvasToGallery}
icon={<PiFloppyDiskBold />}
aria-label={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
colorScheme="invokeBlue"
tooltip={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
isDisabled={isBusy}
/>

View File

@@ -1,5 +1,5 @@
import { Mutex } from 'async-mutex';
import type { ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context';
import type { ProgressData, ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
@@ -135,8 +135,8 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging));
};
connectToSession = ($selectedItemId: Atom<number | null>, $progressData: ProgressDataMap) =>
effect([$selectedItemId, $progressData], (selectedItemId, progressData) => {
connectToSession = ($selectedItemId: Atom<number | null>, $progressData: ProgressDataMap) => {
const cb = (selectedItemId: number | null, progressData: Record<number, ProgressData | undefined>) => {
if (!selectedItemId) {
this.$imageSrc.set(null);
return;
@@ -153,7 +153,14 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
} else {
this.$imageSrc.set(null);
}
});
};
// Run the effect & forcibly render once to initialize
cb($selectedItemId.get(), $progressData.get());
this.render();
return effect([$selectedItemId, $progressData], cb);
};
private _getImageFromSrc = (
{ type, data }: ImageNameSrc | DataURLSrc,

View File

@@ -22,7 +22,7 @@ export const DeleteImageModal = memo(() => {
return (
<ConfirmationAlertDialog
title={`${t('gallery.deleteImage', { count: state.imageDTOs.length })}2`}
title={`${t('gallery.deleteImage', { count: state.image_names.length })}2`}
isOpen={state.isOpen}
onClose={api.close}
cancelButtonText={t('common.cancel')}

View File

@@ -19,17 +19,16 @@ import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from '
import { isInvocationNode } from 'features/nodes/types/invocation';
import { selectUpscaleSlice, type UpscaleState } from 'features/parameters/store/upscaleSlice';
import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { forEach, intersectionBy, some } from 'lodash-es';
import { forEach, intersection, some } from 'lodash-es';
import { atom } from 'nanostores';
import { useMemo } from 'react';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
// Implements an awaitable modal dialog for deleting images
type DeleteImagesModalState = {
imageDTOs: ImageDTO[];
image_names: string[];
usagePerImage: ImageUsage[];
usageSummary: ImageUsage;
isOpen: boolean;
@@ -38,7 +37,7 @@ type DeleteImagesModalState = {
};
const getInitialState = (): DeleteImagesModalState => ({
imageDTOs: [],
image_names: [],
usagePerImage: [],
usageSummary: {
isControlLayerImage: false,
@@ -54,21 +53,21 @@ const getInitialState = (): DeleteImagesModalState => ({
const $deleteModalState = atom<DeleteImagesModalState>(getInitialState());
const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
const deleteImagesWithDialog = async (image_names: string[]): Promise<void> => {
const { getState, dispatch } = getStore();
const imageUsage = getImageUsageFromImageDTOs(imageDTOs, getState());
const imageUsage = getImageUsageFromImageNames(image_names, getState());
const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState());
if (!shouldConfirmOnDelete && !isAnyImageInUse(imageUsage)) {
// If we don't need to confirm and the images are not in use, delete them directly
await handleDeletions(imageDTOs, dispatch, getState);
await handleDeletions(image_names, dispatch, getState);
}
return new Promise<void>((resolve, reject) => {
$deleteModalState.set({
usagePerImage: imageUsage,
usageSummary: getImageUsageSummary(imageUsage),
imageDTOs,
image_names,
isOpen: true,
resolve,
reject,
@@ -76,12 +75,12 @@ const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
});
};
const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, getState: AppGetState) => {
const handleDeletions = async (image_names: string[], dispatch: AppDispatch, getState: AppGetState) => {
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap();
if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) {
if (intersection(state.gallery.selection, image_names).length > 0) {
// Some selected images were deleted, need to select the next image
const queryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
@@ -93,11 +92,11 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
for (const imageDTO of imageDTOs) {
deleteNodesImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
deleteReferenceImages(state, dispatch, imageDTO);
deleteRasterLayerImages(state, dispatch, imageDTO);
for (const image_name of image_names) {
deleteNodesImages(state, dispatch, image_name);
deleteControlLayerImages(state, dispatch, image_name);
deleteReferenceImages(state, dispatch, image_name);
deleteRasterLayerImages(state, dispatch, image_name);
}
} catch {
// no-op
@@ -106,7 +105,7 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get
const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => {
const state = $deleteModalState.get();
await handleDeletions(state.imageDTOs, dispatch, getState);
await handleDeletions(state.image_names, dispatch, getState);
state.resolve?.();
closeSilently();
};
@@ -142,8 +141,8 @@ export const useDeleteImageModalApi = () => {
return api;
};
const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): ImageUsage[] => {
if (imageDTOs.length === 0) {
const getImageUsageFromImageNames = (image_names: string[], state: RootState): ImageUsage[] => {
if (image_names.length === 0) {
return [];
}
@@ -152,7 +151,7 @@ const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): Im
const upscale = selectUpscaleSlice(state);
const refImages = selectRefImagesSlice(state);
return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
return image_names.map((image_name) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
};
const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({
@@ -178,7 +177,7 @@ const isAnyImageInUse = (imageUsage: ImageUsage[]): boolean =>
);
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
const actions: Param0<typeof dispatch>[] = [];
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
@@ -186,7 +185,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
}
forEach(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
if (isImageFieldInputInstance(input) && input.value?.image_name === image_name) {
actions.push(
fieldImageValueChanged({
nodeId: node.data.id,
@@ -201,7 +200,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
fieldImageCollectionValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
value: input.value?.filter((value) => value?.image_name !== image_name),
})
);
}
@@ -211,11 +210,11 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
actions.forEach(dispatch);
};
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => {
let shouldDelete = false;
for (const obj of objects) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) {
shouldDelete = true;
break;
}
@@ -226,19 +225,19 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image
});
};
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
selectReferenceImageEntities(state).forEach((entity) => {
if (entity.config.image?.image_name === imageDTO.image_name) {
if (entity.config.image?.image_name === image_name) {
dispatch(refImageImageChanged({ id: entity.id, imageDTO: null }));
}
});
};
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => {
let shouldDelete = false;
for (const obj of objects) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) {
shouldDelete = true;
break;
}

View File

@@ -6,10 +6,9 @@ import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } f
import { memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[] }) => {
const DndDragPreviewMultipleImage = memo(({ image_names }: { image_names: string[] }) => {
const { t } = useTranslation();
return (
<Flex
@@ -21,7 +20,7 @@ const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[]
bg="base.900"
borderRadius="base"
>
<Heading>{imageDTOs.length}</Heading>
<Heading>{image_names.length}</Heading>
<Heading size="sm">{t('parameters.images')}</Heading>
</Flex>
);
@@ -32,11 +31,11 @@ DndDragPreviewMultipleImage.displayName = 'DndDragPreviewMultipleImage';
export type DndDragPreviewMultipleImageState = {
type: 'multiple-image';
container: HTMLElement;
imageDTOs: ImageDTO[];
image_names: string[];
};
export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageState) =>
createPortal(<DndDragPreviewMultipleImage imageDTOs={arg.imageDTOs} />, arg.container);
createPortal(<DndDragPreviewMultipleImage image_names={arg.image_names} />, arg.container);
type SetMultipleDragPreviewArg = {
multipleImageDndData: MultipleImageDndSourceData;
@@ -52,7 +51,7 @@ export const setMultipleImageDragPreview = ({
const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
setCustomNativeDragPreview({
render({ container }) {
setDragPreviewState({ type: 'multiple-image', container, imageDTOs: multipleImageDndData.payload.imageDTOs });
setDragPreviewState({ type: 'multiple-image', container, image_names: multipleImageDndData.payload.image_names });
return () => setDragPreviewState(null);
},
nativeSetDragImage,

View File

@@ -87,7 +87,7 @@ const _multipleImage = buildTypeAndKey('multiple-image');
export type MultipleImageDndSourceData = DndData<
typeof _multipleImage.type,
typeof _multipleImage.key,
{ imageDTOs: ImageDTO[]; boardId: BoardId }
{ image_names: string[]; board_id: BoardId }
>;
export const multipleImageDndSource: DndSource<MultipleImageDndSourceData> = {
..._multipleImage,
@@ -305,7 +305,7 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget<
if (singleImageDndSource.typeGuard(sourceData)) {
newValue.push({ image_name: sourceData.payload.imageDTO.image_name });
} else {
newValue.push(...sourceData.payload.imageDTOs.map(({ image_name }) => ({ image_name })));
newValue.push(...sourceData.payload.image_names.map((image_name) => ({ image_name })));
}
dispatch(fieldImageCollectionValueChanged({ ...fieldIdentifier, value: newValue }));
@@ -330,17 +330,17 @@ export const setComparisonImageDndTarget: DndTarget<SetComparisonImageDndTargetD
}
const { firstImage, secondImage } = selectComparisonImages(getState());
// Do not allow the same images to be selected for comparison
if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) {
if (sourceData.payload.imageDTO.image_name === firstImage) {
return false;
}
if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) {
if (sourceData.payload.imageDTO.image_name === secondImage) {
return false;
}
return true;
},
handler: ({ sourceData, dispatch }) => {
const { imageDTO } = sourceData.payload;
setComparisonImage({ imageDTO, dispatch });
setComparisonImage({ image_name: imageDTO.image_name, dispatch });
},
};
//#endregion
@@ -450,7 +450,7 @@ export const addImageToBoardDndTarget: DndTarget<
return currentBoard !== destinationBoard;
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
const currentBoard = sourceData.payload.board_id;
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
@@ -460,13 +460,13 @@ export const addImageToBoardDndTarget: DndTarget<
if (singleImageDndSource.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
const { boardId } = targetData.payload;
addImagesToBoard({ imageDTOs: [imageDTO], boardId, dispatch });
addImagesToBoard({ image_names: [imageDTO.image_name], boardId, dispatch });
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const { imageDTOs } = sourceData.payload;
const { image_names } = sourceData.payload;
const { boardId } = targetData.payload;
addImagesToBoard({ imageDTOs, boardId, dispatch });
addImagesToBoard({ image_names, boardId, dispatch });
}
},
};
@@ -494,7 +494,7 @@ export const removeImageFromBoardDndTarget: DndTarget<
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
const currentBoard = sourceData.payload.board_id;
return currentBoard !== 'none';
}
@@ -503,12 +503,12 @@ export const removeImageFromBoardDndTarget: DndTarget<
handler: ({ sourceData, dispatch }) => {
if (singleImageDndSource.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
removeImagesFromBoard({ imageDTOs: [imageDTO], dispatch });
removeImagesFromBoard({ image_names: [imageDTO.image_name], dispatch });
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const { imageDTOs } = sourceData.payload;
removeImagesFromBoard({ imageDTOs, dispatch });
const { image_names } = sourceData.payload;
removeImagesFromBoard({ image_names, dispatch });
}
},
};

View File

@@ -91,7 +91,7 @@ const DeleteBoardModal = () => {
if (!boardToDelete || boardToDelete === 'none') {
return;
}
deleteBoardOnly(boardToDelete.board_id);
deleteBoardOnly({ board_id: boardToDelete.board_id });
$boardToDelete.set(null);
}, [boardToDelete, deleteBoardOnly]);
@@ -99,7 +99,7 @@ const DeleteBoardModal = () => {
if (!boardToDelete || boardToDelete === 'none') {
return;
}
deleteBoardAndImages(boardToDelete.board_id);
deleteBoardAndImages({ board_id: boardToDelete.board_id });
$boardToDelete.set(null);
}, [boardToDelete, deleteBoardAndImages]);

View File

@@ -25,9 +25,8 @@ import { useBoardName } from 'services/api/hooks/useBoardName';
import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover';
import { GalleryUploadButton } from './GalleryUploadButton';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
import { GalleryPagination } from './ImageGrid/GalleryPagination';
import { GallerySearch } from './ImageGrid/GallerySearch';
import { NewGallery } from './NewGallery';
const BASE_STYLES: ChakraProps['sx'] = {
fontWeight: 'semibold',
@@ -71,7 +70,7 @@ export const GalleryPanel = memo(() => {
return (
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full" p={2} minH={0}>
<Tabs index={galleryView === 'images' ? 0 : 1} variant="enclosed" display="flex" flexDir="column" w="full">
<Tabs index={galleryView === 'images' ? 0 : 1} variant="enclosed" display="flex" flexDir="column" w="full" pb={2}>
<TabList gap={2} fontSize="sm" borderColor="base.800" alignItems="center" w="full">
<Text fontSize="sm" fontWeight="semibold" noOfLines={1} px="2" wordBreak="break-all">
{boardName}
@@ -90,6 +89,7 @@ export const GalleryPanel = memo(() => {
<Flex h="full" justifyContent="flex-end">
<GalleryUploadButton />
<GallerySettingsPopover />
<IconButton
size="sm"
variant="link"
@@ -101,19 +101,20 @@ export const GalleryPanel = memo(() => {
/>
</Flex>
</TabList>
<Collapse in={searchDisclosure.isOpen} style={COLLAPSE_STYLES}>
<Box w="full" pt={2}>
<GallerySearch
searchTerm={searchTerm}
onChangeSearchTerm={onChangeSearchTerm}
onResetSearchTerm={onResetSearchTerm}
/>
</Box>
</Collapse>
</Tabs>
<Collapse in={searchDisclosure.isOpen} style={COLLAPSE_STYLES}>
<Box w="full" pt={2}>
<GallerySearch
searchTerm={searchTerm}
onChangeSearchTerm={onChangeSearchTerm}
onResetSearchTerm={onResetSearchTerm}
/>
</Box>
</Collapse>
<GalleryImageGrid />
<GalleryPagination />
{/* <GalleryImageGrid />
<GalleryPagination /> */}
<NewGallery />
</Flex>
);
});

View File

@@ -12,7 +12,7 @@ export const ImageMenuItemChangeBoard = memo(() => {
const imageDTO = useImageDTOContext();
const onClick = useCallback(() => {
dispatch(imagesToChangeSelected([imageDTO]));
dispatch(imagesToChangeSelected([imageDTO.image_name]));
dispatch(isModalOpenChanged(true));
}, [dispatch, imageDTO]);

View File

@@ -12,7 +12,7 @@ export const ImageMenuItemDelete = memo(() => {
const onClick = useCallback(async () => {
try {
await deleteImageModal.delete([imageDTO]);
await deleteImageModal.delete([imageDTO.image_name]);
} catch {
// noop;
}

View File

@@ -12,7 +12,7 @@ export const ImageMenuItemOpenInViewer = memo(() => {
const imageDTO = useImageDTOContext();
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO));
dispatch(imageSelected(imageDTO.image_name));
// TODO: figure out how to select the closest image viewer...
}, [dispatch, imageDTO]);

View File

@@ -12,13 +12,13 @@ export const ImageMenuItemSelectForCompare = memo(() => {
const dispatch = useAppDispatch();
const imageDTO = useImageDTOContext();
const selectMaySelectForCompare = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name !== imageDTO.image_name),
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare !== imageDTO.image_name),
[imageDTO.image_name]
);
const maySelectForCompare = useAppSelector(selectMaySelectForCompare);
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(imageDTO));
dispatch(imageToCompareChanged(imageDTO.image_name));
}, [dispatch, imageDTO]);
return (

View File

@@ -16,13 +16,13 @@ export const ImageMenuItemStarUnstar = memo(() => {
const starImage = useCallback(() => {
if (imageDTO) {
starImages({ imageDTOs: [imageDTO] });
starImages({ image_names: [imageDTO.image_name] });
}
}, [starImages, imageDTO]);
const unstarImage = useCallback(() => {
if (imageDTO) {
unstarImages({ imageDTOs: [imageDTO] });
unstarImages({ image_names: [imageDTO.image_name] });
}
}, [unstarImages, imageDTO]);

View File

@@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold, PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi';
import {
@@ -37,37 +37,25 @@ const MultipleSelectionMenuItems = () => {
}, [deleteImageModal, selection]);
const handleStarSelection = useCallback(() => {
starImages({ imageDTOs: selection });
starImages({ image_names: selection });
}, [starImages, selection]);
const handleUnstarSelection = useCallback(() => {
unstarImages({ imageDTOs: selection });
unstarImages({ image_names: selection });
}, [unstarImages, selection]);
const handleBulkDownload = useCallback(() => {
bulkDownload({ image_names: selection.map((img) => img.image_name) });
bulkDownload({ image_names: selection });
}, [selection, bulkDownload]);
const areAllStarred = useMemo(() => {
return selection.every((img) => img.starred);
}, [selection]);
const areAllUnstarred = useMemo(() => {
return selection.every((img) => !img.starred);
}, [selection]);
return (
<>
{areAllStarred && (
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarBold />} onClickCapture={handleUnstarSelection}>
{customStarUi ? customStarUi.off.text : `Unstar All`}
</MenuItem>
)}
{(areAllUnstarred || (!areAllStarred && !areAllUnstarred)) && (
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarFill />} onClickCapture={handleStarSelection}>
{customStarUi ? customStarUi.on.text : `Star All`}
</MenuItem>
)}
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarBold />} onClickCapture={handleUnstarSelection}>
{customStarUi ? customStarUi.off.text : `Unstar All`}
</MenuItem>
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarFill />} onClickCapture={handleStarSelection}>
{customStarUi ? customStarUi.on.text : `Star All`}
</MenuItem>
{isBulkDownloadEnabled && (
<MenuItem icon={<PiDownloadSimpleBold />} onClickCapture={handleBulkDownload}>
{t('gallery.downloadSelection')}

View File

@@ -17,6 +17,7 @@ import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/Ga
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
import type { MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
@@ -92,7 +93,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
const ref = useRef<HTMLImageElement>(null);
const dndId = useId();
const selectIsSelectedForCompare = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name),
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare === imageDTO.image_name),
[imageDTO.image_name]
);
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
@@ -100,7 +101,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
() =>
createSelector(selectGallerySlice, (gallery) => {
for (const selectedImage of gallery.selection) {
if (selectedImage.image_name === imageDTO.image_name) {
if (selectedImage === imageDTO.image_name) {
return true;
}
}
@@ -125,11 +126,11 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
// multi-image drag.
if (
gallery.selection.length > 1 &&
gallery.selection.find(({ image_name }) => image_name === imageDTO.image_name) !== undefined
gallery.selection.find((image_name) => image_name === imageDTO.image_name) !== undefined
) {
return multipleImageDndSource.getData({
imageDTOs: gallery.selection,
boardId: gallery.selectedBoardId,
image_names: gallery.selection,
board_id: gallery.selectedBoardId,
});
}
@@ -166,7 +167,10 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
onDragStart: ({ source }) => {
// When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
// selection. This is called for all drag events.
if (multipleImageDndSource.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) {
if (
multipleImageDndSource.typeGuard(source.data) &&
source.data.payload.image_names.includes(imageDTO.image_name)
) {
setIsDragging(true);
}
},
@@ -192,7 +196,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
(e) => {
store.dispatch(
galleryImageClicked({
imageDTO,
imageName: imageDTO.image_name,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
@@ -205,7 +209,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(() => {
store.dispatch(imageToCompareChanged(null));
autoLayoutContext.focusImageViewer();
autoLayoutContext.focusPanel(VIEWER_PANEL_ID);
}, [autoLayoutContext, store]);
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);

View File

@@ -22,7 +22,7 @@ export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
if (!imageDTO) {
return;
}
deleteImageModal.delete([imageDTO]);
deleteImageModal.delete([imageDTO.image_name]);
},
[deleteImageModal, imageDTO]
);

View File

@@ -2,6 +2,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsOutBold } from 'react-icons/pi';
@@ -13,14 +14,14 @@ type Props = {
export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => {
const dispatch = useAppDispatch();
const { focusImageViewer } = useAutoLayoutContext();
const { focusPanel } = useAutoLayoutContext();
const { t } = useTranslation();
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO));
focusImageViewer();
}, [dispatch, focusImageViewer, imageDTO]);
dispatch(imageSelected(imageDTO.image_name));
focusPanel(VIEWER_PANEL_ID);
}, [dispatch, focusPanel, imageDTO]);
return (
<DndImageIcon

View File

@@ -17,9 +17,9 @@ export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => {
const toggleStarredState = useCallback(() => {
if (imageDTO.starred) {
unstarImages({ imageDTOs: [imageDTO] });
unstarImages({ image_names: [imageDTO.image_name] });
} else {
starImages({ imageDTOs: [imageDTO] });
starImages({ image_names: [imageDTO.image_name] });
}
}, [starImages, unstarImages, imageDTO]);

View File

@@ -1,11 +1,10 @@
import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { useDebouncedImageCollectionQueryArgs } from 'features/gallery/components/NewGallery';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { useListImagesQuery } from 'services/api/endpoints/images';
import { useGetImageCollectionCountsQuery } from 'services/api/endpoints/images';
type Props = {
searchTerm: string;
@@ -15,10 +14,8 @@ type Props = {
export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => {
const { t } = useTranslation();
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const { isPending } = useListImagesQuery(queryArgs, {
selectFromResult: ({ isLoading, isFetching }) => ({ isPending: isLoading || isFetching }),
});
const queryArgs = useDebouncedImageCollectionQueryArgs();
const { isFetching } = useGetImageCollectionCountsQuery(queryArgs);
const handleChangeInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@@ -46,12 +43,12 @@ export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSear
data-testid="image-search-input"
onKeyDown={handleKeydown}
/>
{isPending && (
{isFetching && (
<InputRightElement h="full" pe={2}>
<Spinner size="sm" opacity={0.5} />
</InputRightElement>
)}
{!isPending && searchTerm.length && (
{!isFetching && searchTerm.length && (
<InputRightElement h="full" pe={2}>
<IconButton
onClick={onResetSearchTerm}

View File

@@ -19,7 +19,7 @@ export const GallerySelectionCountTag = memo(() => {
const isGalleryFocused = useIsRegionFocused('gallery');
const onSelectPage = useCallback(() => {
dispatch(selectionChanged([...selection, ...imageDTOs]));
dispatch(selectionChanged([...selection, ...imageDTOs.map(({ image_name }) => image_name)]));
}, [dispatch, selection, imageDTOs]);
useRegisteredHotkeys({

View File

@@ -1,8 +1,7 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectSearchTerm } from 'features/gallery/store/gallerySelectors';
import { searchTermChanged } from 'features/gallery/store/gallerySlice';
import { debounce } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react';
import { useCallback } from 'react';
export const useGallerySearchTerm = () => {
// Highlander!
@@ -11,27 +10,16 @@ export const useGallerySearchTerm = () => {
const dispatch = useAppDispatch();
const searchTerm = useAppSelector(selectSearchTerm);
const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm);
const debouncedSetSearchTerm = useMemo(() => {
return debounce((val: string) => {
dispatch(searchTermChanged(val));
}, 1000);
}, [dispatch]);
const onChange = useCallback(
(val: string) => {
setLocalSearchTerm(val);
debouncedSetSearchTerm(val);
dispatch(searchTermChanged(val));
},
[debouncedSetSearchTerm]
[dispatch]
);
const onReset = useCallback(() => {
debouncedSetSearchTerm.cancel();
setLocalSearchTerm('');
dispatch(searchTermChanged(''));
}, [debouncedSetSearchTerm, dispatch]);
}, [dispatch]);
return [localSearchTerm, onChange, onReset] as const;
return [searchTerm, onChange, onReset] as const;
};

View File

@@ -1,6 +1,5 @@
import { Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
@@ -10,6 +9,7 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -21,14 +21,22 @@ import {
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useImageDTO } from 'services/api/endpoints/images';
import { useImageViewerContext } from './context';
export const CurrentImageButtons = memo(() => {
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
const { t } = useTranslation();
const ctx = useImageViewerContext();
const hasProgressImage = useStore(ctx.$hasProgressImage);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
const imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
const hasTemplates = useStore($hasTemplates);
const imageActions = useImageActions(imageDTO ?? null);
const imageActions = useImageActions(imageDTO);
const isStaging = useAppSelector(selectIsStaging);
const isUpscalingEnabled = useFeatureStatus('upscaling');
@@ -39,7 +47,7 @@ export const CurrentImageButtons = memo(() => {
as={IconButton}
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={!imageDTO}
isDisabled={isDisabledOverride || !imageDTO}
variant="link"
alignSelf="stretch"
icon={<PiDotsThreeOutlineFill />}
@@ -53,7 +61,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!imageDTO || !imageActions.hasWorkflow || !hasTemplates}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasWorkflow || !hasTemplates}
variant="link"
alignSelf="stretch"
onClick={imageActions.loadWorkflow}
@@ -62,7 +70,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiArrowsCounterClockwiseBold />}
tooltip={`${t('parameters.remixImage')} (R)`}
aria-label={`${t('parameters.remixImage')} (R)`}
isDisabled={!imageDTO || !imageActions.hasMetadata}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
variant="link"
alignSelf="stretch"
onClick={imageActions.remix}
@@ -71,7 +79,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!imageDTO || !imageActions.hasPrompts}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasPrompts}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallPrompts}
@@ -80,7 +88,7 @@ export const CurrentImageButtons = memo(() => {
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!imageDTO || !imageActions.hasSeed}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasSeed}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallSeed}
@@ -92,23 +100,23 @@ export const CurrentImageButtons = memo(() => {
variant="link"
alignSelf="stretch"
onClick={imageActions.recallSize}
isDisabled={!imageDTO || isStaging}
isDisabled={isDisabledOverride || !imageDTO || isStaging}
/>
<IconButton
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!imageDTO || !imageActions.hasMetadata}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallAll}
/>
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} />}
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={isDisabledOverride} />}
<Divider orientation="vertical" h={8} mx={2} />
<DeleteImageButton onClick={imageActions.delete} isDisabled={!imageDTO} />
<DeleteImageButton onClick={imageActions.delete} isDisabled={isDisabledOverride || !imageDTO} />
</>
);
});

View File

@@ -1,20 +1,30 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress';
import { DndImage } from 'features/dnd/DndImage';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors';
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useCallback, useRef, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { ImageDTO, S } from 'services/api/types';
import { $socket } from 'services/events/stores';
import { ImageMetadataMini } from './ImageMetadataMini';
import { NoContentForViewer } from './NoContentForViewer';
import { ProgressImage } from './ProgressImage2';
import { ProgressIndicator } from './ProgressIndicator2';
export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => {
export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => {
const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const socket = useStore($socket);
const [progressEvent, setProgressEvent] = useState<S['InvocationProgressEvent'] | null>(null);
const [progressImage, setProgressImage] = useState<ProgressImageType | null>(null);
// Show and hide the next/prev buttons on mouse move
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState<boolean>(false);
@@ -29,6 +39,36 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
}, 500);
}, []);
useEffect(() => {
if (!socket) {
return;
}
const onInvocationProgress = (data: S['InvocationProgressEvent']) => {
setProgressEvent(data);
if (data.image) {
setProgressImage(data.image);
}
};
socket.on('invocation_progress', onInvocationProgress);
return () => {
socket.off('invocation_progress', onInvocationProgress);
};
}, [socket]);
const onLoadImage = useCallback(() => {
if (!progressEvent || !imageDTO) {
return;
}
if (progressEvent.session_id === imageDTO.session_id) {
setProgressImage(null);
}
}, [imageDTO, progressEvent]);
const withProgress = shouldShowProgressInViewer && progressEvent && progressImage;
return (
<Flex
onMouseOver={onMouseOver}
@@ -39,12 +79,23 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
justifyContent="center"
position="relative"
>
<ImageContent imageDTO={imageDTO} />
{imageDTO && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} />
</Flex>
)}
{!imageDTO && <NoContentForViewer />}
{withProgress && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center" bg="base.900">
<ProgressImage progressImage={progressImage} />
<ProgressIndicator progressEvent={progressEvent} position="absolute" top={6} right={6} size={8} />
</Flex>
)}
<Flex flexDir="column" gap={2} position="absolute" top={0} insetInlineStart={0} alignItems="flex-start">
<CanvasAlertsInvocationProgress />
{imageDTO && <ImageMetadataMini imageName={imageDTO.image_name} />}
{imageDTO && !withProgress && <ImageMetadataMini imageName={imageDTO.image_name} />}
</Flex>
{shouldShowImageDetails && imageDTO && (
{shouldShowImageDetails && imageDTO && !withProgress && (
<Box position="absolute" opacity={0.8} top={0} width="full" height="full" borderRadius="base">
<ImageMetadataViewer image={imageDTO} />
</Box>
@@ -73,19 +124,6 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO })
});
CurrentImagePreview.displayName = 'CurrentImagePreview';
const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => {
if (!imageDTO) {
return <NoContentForViewer />;
}
return (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
<DndImage imageDTO={imageDTO} />
</Flex>
);
});
ImageContent.displayName = 'ImageContent';
const initial: AnimationProps['initial'] = {
opacity: 0,
};

View File

@@ -2,9 +2,12 @@ import { Flex } from '@invoke-ai/ui-library';
import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage';
import { memo } from 'react';
import { ProgressIndicator } from './ProgressIndicator';
export const GenerationProgressPanel = memo(() => (
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2}>
<Flex position="relative" flexDir="column" w="full" h="full" overflow="hidden" p={2}>
<ProgressImage />
<ProgressIndicator position="absolute" top={6} right={6} size={8} />
</Flex>
));
GenerationProgressPanel.displayName = 'GenerationProgressPanel';

View File

@@ -1,11 +1,10 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview';
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useImageDTO } from 'services/api/endpoints/images';
// type Props = {
// closeButton?: ReactNode;
@@ -28,9 +27,10 @@ import { useGetImageDTOQuery } from 'services/api/endpoints/images';
// };
export const ImageViewer = memo(() => {
const lastSelectedImageName = useAppSelector(selectLastSelectedImageName);
const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken);
const comparisonImageDTO = useAppSelector(selectImageToCompare);
const lastSelectedImageName = useAppSelector(selectLastSelectedImage);
const lastSelectedImageDTO = useImageDTO(lastSelectedImageName);
const comparisonImageName = useAppSelector(selectImageToCompare);
const comparisonImageDTO = useImageDTO(comparisonImageName);
if (lastSelectedImageDTO && comparisonImageDTO) {
return <ImageComparison firstImage={lastSelectedImageDTO} secondImage={comparisonImageDTO} />;

View File

@@ -3,11 +3,17 @@ import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
import { memo } from 'react';
export const ImageViewerPanel = memo(() => (
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2} gap={2}>
<ViewerToolbar />
<Divider />
<ImageViewer />
</Flex>
));
import { ImageViewerContextProvider } from './context';
export const ImageViewerPanel = memo(() => {
return (
<ImageViewerContextProvider>
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2} gap={2}>
<ViewerToolbar />
<Divider />
<ImageViewer />
</Flex>
</ImageViewerContextProvider>
);
});
ImageViewerPanel.displayName = 'ImageViewerPanel';

View File

@@ -0,0 +1,22 @@
import { Flex } from '@invoke-ai/ui-library';
import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2';
import { ProgressIndicator } from 'features/gallery/components/ImageViewer/ProgressIndicator2';
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
import { memo } from 'react';
import type { S } from 'services/api/types';
export const Progress = memo(
({
progressEvent,
progressImage,
}: {
progressEvent: S['InvocationProgressEvent'];
progressImage: ProgressImageType;
}) => (
<Flex position="relative" flexDir="column" w="full" h="full" overflow="hidden" p={2}>
<ProgressImage progressImage={progressImage} />
<ProgressIndicator progressEvent={progressEvent} position="absolute" top={6} right={6} size={8} />
</Flex>
)
);
Progress.displayName = 'Progress';

View File

@@ -1,5 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, Image } from '@invoke-ai/ui-library';
import { Flex, Heading, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
@@ -7,6 +7,7 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { selectSystemSlice } from 'features/system/store/systemSlice';
import { memo, useMemo } from 'react';
import { PiPulseBold } from 'react-icons/pi';
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
import { $lastProgressImage } from 'services/events/stores';
const selectShouldAntialiasProgressImage = createSelector(
@@ -15,6 +16,7 @@ const selectShouldAntialiasProgressImage = createSelector(
);
export const ProgressImage = memo(() => {
const isGenerationInProgress = useIsGenerationInProgress();
const progressImage = useStore($lastProgressImage);
const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage);
@@ -25,7 +27,7 @@ export const ProgressImage = memo(() => {
[shouldAntialiasProgressImage]
);
if (!progressImage) {
if (!isGenerationInProgress) {
return (
<Flex width="full" height="full" alignItems="center" justifyContent="center">
<IAINoContentFallback icon={PiPulseBold} label="No Generation in Progress" />
@@ -33,6 +35,14 @@ export const ProgressImage = memo(() => {
);
}
if (!progressImage) {
return (
<Flex width="full" height="full" alignItems="center" justifyContent="center" minW={0} minH={0}>
<Heading>Waiting for Image</Heading>
</Flex>
);
}
return (
<Flex width="full" height="full" alignItems="center" justifyContent="center" minW={0} minH={0}>
<Image

View File

@@ -0,0 +1,44 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, Image } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
import { selectSystemSlice } from 'features/system/store/systemSlice';
import { memo, useMemo } from 'react';
const selectShouldAntialiasProgressImage = createSelector(
selectSystemSlice,
(system) => system.shouldAntialiasProgressImage
);
export const ProgressImage = memo(({ progressImage }: { progressImage: ProgressImageType }) => {
const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage);
const sx = useMemo<SystemStyleObject>(
() => ({
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
}),
[shouldAntialiasProgressImage]
);
return (
<Flex width="full" height="full" alignItems="center" justifyContent="center" minW={0} minH={0}>
<Image
src={progressImage.dataURL}
width={progressImage.width}
height={progressImage.height}
draggable={false}
data-testid="progress-image"
objectFit="contain"
maxWidth="full"
maxHeight="full"
borderRadius="base"
sx={sx}
minH={0}
minW={0}
/>
</Flex>
);
});
ProgressImage.displayName = 'ProgressImage';

View File

@@ -0,0 +1,38 @@
import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { memo } from 'react';
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
import { $lastProgressEvent, formatProgressMessage } from 'services/events/stores';
const circleStyles: SystemStyleObject = {
circle: {
transitionProperty: 'none',
transitionDuration: '0s',
},
};
export const ProgressIndicator = memo((props: CircularProgressProps) => {
const isGenerationInProgress = useIsGenerationInProgress();
const lastProgressEvent = useStore($lastProgressEvent);
if (!isGenerationInProgress) {
return null;
}
if (!lastProgressEvent) {
return null;
}
return (
<Tooltip label={formatProgressMessage(lastProgressEvent)}>
<CircularProgress
size="14px"
color="invokeBlue.500"
thickness={14}
isIndeterminate={!lastProgressEvent || lastProgressEvent.percentage === null}
value={lastProgressEvent?.percentage ? lastProgressEvent.percentage * 100 : undefined}
sx={circleStyles}
{...props}
/>
</Tooltip>
);
});
ProgressIndicator.displayName = 'ProgressMessage';

View File

@@ -0,0 +1,31 @@
import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
import { memo } from 'react';
import type { S } from 'services/api/types';
import { formatProgressMessage } from 'services/events/stores';
const circleStyles: SystemStyleObject = {
circle: {
transitionProperty: 'none',
transitionDuration: '0s',
},
};
export const ProgressIndicator = memo(
({ progressEvent, ...rest }: { progressEvent: S['InvocationProgressEvent'] } & CircularProgressProps) => {
return (
<Tooltip label={formatProgressMessage(progressEvent)}>
<CircularProgress
size="14px"
color="invokeBlue.500"
thickness={14}
isIndeterminate={!progressEvent || progressEvent.percentage === null}
value={progressEvent?.percentage ? progressEvent.percentage * 100 : undefined}
sx={circleStyles}
{...rest}
/>
</Tooltip>
);
}
);
ProgressIndicator.displayName = 'ProgressMessage';

View File

@@ -1,15 +1,24 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors';
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import { setShouldShowImageDetails } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiInfoBold } from 'react-icons/pi';
import { useImageViewerContext } from './context';
export const ToggleMetadataViewerButton = memo(() => {
const dispatch = useAppDispatch();
const ctx = useImageViewerContext();
const hasProgressImage = useStore(ctx.$hasProgressImage);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails);
const imageDTO = useAppSelector(selectLastSelectedImage);
const { t } = useTranslation();
@@ -35,6 +44,7 @@ export const ToggleMetadataViewerButton = memo(() => {
alignSelf="stretch"
colorScheme={shouldShowImageDetails ? 'invokeBlue' : 'base'}
data-testid="toggle-show-metadata-button"
isDisabled={isDisabledOverride}
/>
);
});

View File

@@ -1,16 +1,18 @@
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
import { memo } from 'react';
import { CurrentImageButtons } from './CurrentImageButtons';
import { ToggleProgressButton } from './ToggleProgressButton';
export const ViewerToolbar = memo(() => {
return (
<Flex w="full" justifyContent="center" h={8}>
<ButtonGroup>
<ToggleMetadataViewerButton />
<CurrentImageButtons />
</ButtonGroup>
<ToggleProgressButton />
<Spacer />
<CurrentImageButtons />
<Spacer />
<ToggleMetadataViewerButton />
</Flex>
);
});

View File

@@ -0,0 +1,71 @@
import { useStore } from '@nanostores/react';
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
import { type Atom, atom, computed } from 'nanostores';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ImageDTO, S } from 'services/api/types';
import { $socket } from 'services/events/stores';
import { assert } from 'tsafe';
type ImageViewerContextValue = {
$progressEvent: Atom<S['InvocationProgressEvent'] | null>;
$progressImage: Atom<ProgressImageType | null>;
$hasProgressImage: Atom<boolean>;
onLoadImage: (imageDTO: ImageDTO) => void;
};
const ImageViewerContext = createContext<ImageViewerContextValue | null>(null);
export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
const socket = useStore($socket);
const $progressEvent = useState(() => atom<S['InvocationProgressEvent'] | null>(null))[0];
const $progressImage = useState(() => atom<ProgressImageType | null>(null))[0];
const $hasProgressImage = useState(() => computed($progressImage, (progressImage) => progressImage !== null))[0];
useEffect(() => {
if (!socket) {
return;
}
const onInvocationProgress = (data: S['InvocationProgressEvent']) => {
$progressEvent.set(data);
if (data.image) {
$progressImage.set(data.image);
}
};
socket.on('invocation_progress', onInvocationProgress);
return () => {
socket.off('invocation_progress', onInvocationProgress);
};
}, [$progressEvent, $progressImage, socket]);
const onLoadImage = useCallback(
(imageDTO: ImageDTO) => {
const progressEvent = $progressEvent.get();
if (!progressEvent || !imageDTO) {
return;
}
if (progressEvent.session_id === imageDTO.session_id) {
$progressEvent.set(null);
$progressImage.set(null);
}
},
[$progressEvent, $progressImage]
);
const value = useMemo(
() => ({ $progressEvent, $progressImage, $hasProgressImage, onLoadImage }),
[$hasProgressImage, $progressEvent, $progressImage, onLoadImage]
);
return <ImageViewerContext.Provider value={value}>{props.children}</ImageViewerContext.Provider>;
});
ImageViewerContextProvider.displayName = 'ImageViewerContextProvider';
export const useImageViewerContext = () => {
const value = useContext(ImageViewerContext);
assert(value !== null, 'useImageViewerContext must be used within a ImageViewerContextProvider');
return value;
};

View File

@@ -0,0 +1,464 @@
import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectGalleryImageMinimumWidth,
selectImageCollectionQueryArgs,
selectLastSelectedImage,
} from 'features/gallery/store/gallerySelectors';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { MutableRefObject } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
GridComponents,
GridComputeItemKey,
GridItemContent,
ListRange,
ScrollSeekConfiguration,
VirtuosoGridHandle,
} from 'react-virtuoso';
import { VirtuosoGrid } from 'react-virtuoso';
import { useGetImageNamesQuery, useListImagesQuery } from 'services/api/endpoints/images';
import type { ImageDTO, ListImagesArgs } from 'services/api/types';
import { useDebounce } from 'use-debounce';
import { GalleryImage } from './ImageGrid/GalleryImage';
const log = logger('gallery');
// Constants
const PAGE_SIZE = 100;
const VIEWPORT_BUFFER = 2048;
const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096;
const DEBOUNCE_DELAY = 500;
const GRID_GAP = 1;
const SPINNER_OPACITY = 0.3;
type GridContext = {
queryArgs: ListImagesArgs;
imageNames: string[];
};
export const useDebouncedImageCollectionQueryArgs = () => {
const _galleryQueryArgs = useAppSelector(selectImageCollectionQueryArgs);
const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY);
return queryArgs;
};
// Hook to get an image DTO from cache or trigger loading
const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: ListImagesArgs): ImageDTO | null => {
const { arg, options } = useMemo(() => {
const pageOffset = Math.floor(index / PAGE_SIZE) * PAGE_SIZE;
return {
arg: {
...queryArgs,
offset: pageOffset,
limit: PAGE_SIZE,
} satisfies Parameters<typeof useListImagesQuery>[0],
options: {
selectFromResult: ({ data }) => {
const imageDTO = data?.items?.[index - pageOffset] || null;
if (imageDTO && imageDTO.image_name !== imageName) {
log.warn(`Image at index ${index} does not match expected image name ${imageName}`);
}
return { imageDTO };
},
} satisfies Parameters<typeof useListImagesQuery>[1],
};
}, [index, queryArgs, imageName]);
const { imageDTO } = useListImagesQuery(arg, options);
return imageDTO;
};
// Individual image component that gets its data from RTK Query cache
const ImageAtPosition = memo(
({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesArgs }) => {
const imageDTO = useImageDTOFromListQuery(index, imageName, queryArgs);
if (!imageDTO) {
return <Skeleton w="full" h="full" />;
}
return <GalleryImage imageDTO={imageDTO} />;
}
);
ImageAtPosition.displayName = 'ImageAtPosition';
// Memoized compute key function using image names
const computeItemKey: GridComputeItemKey<string, GridContext> = (index, imageName, { queryArgs }) => {
return `${JSON.stringify(queryArgs)}-${imageName}`;
};
// Physical DOM-based grid calculation using refs (based on working old implementation)
const getImagesPerRow = (rootEl: HTMLDivElement): number => {
// Start from root and find virtuoso grid elements
const gridElement = rootEl.querySelector('.virtuoso-grid-list');
if (!gridElement) {
return 0;
}
const firstGridItem = gridElement.querySelector('.virtuoso-grid-item');
if (!firstGridItem) {
return 0;
}
const itemRect = firstGridItem.getBoundingClientRect();
const containerRect = gridElement.getBoundingClientRect();
// Get the computed gap from CSS
const gridStyle = window.getComputedStyle(gridElement);
const gapValue = gridStyle.gap;
const gap = parseFloat(gapValue);
if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) {
return 0;
}
// Use the exact calculation from the working old implementation
let imagesPerRow = 0;
let spaceUsed = 0;
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
// this, without the possibility of accidentally adding an extra column.
while (spaceUsed + itemRect.width <= containerRect.width + 1) {
imagesPerRow++; // Increment the number of images
spaceUsed += itemRect.width; // Add image size to the used space
if (spaceUsed + gap <= containerRect.width) {
spaceUsed += gap; // Add gap size to the used space after each image except after the last image
}
}
return Math.max(1, imagesPerRow);
};
// Check if an item at a given index is visible in the viewport
const scrollIntoView = (
index: number,
rootEl: HTMLDivElement,
virtuosoGridHandle: VirtuosoGridHandle,
range: ListRange
) => {
if (range.endIndex === 0) {
return;
}
// First get the virtuoso grid list root element
const gridList = rootEl.querySelector('.virtuoso-grid-list') as HTMLElement;
if (!gridList) {
// No grid - cannot scroll!
return;
}
// Then find the specific item within the grid list
const targetItem = gridList.querySelector(`.virtuoso-grid-item[data-index="${index}"]`) as HTMLElement;
if (!targetItem) {
if (index > range.endIndex) {
virtuosoGridHandle.scrollToIndex({
index,
behavior: 'auto',
align: 'start',
});
} else if (index < range.startIndex) {
virtuosoGridHandle.scrollToIndex({
index,
behavior: 'auto',
align: 'end',
});
} else {
log.warn(`Unable to find item index ${index} but it is in range ${range.startIndex}-${range.endIndex}`);
}
return;
}
const itemRect = targetItem.getBoundingClientRect();
const rootRect = rootEl.getBoundingClientRect();
if (itemRect.top < rootRect.top) {
virtuosoGridHandle.scrollToIndex({
index,
behavior: 'auto',
align: 'start',
});
} else if (itemRect.bottom > rootRect.bottom) {
virtuosoGridHandle.scrollToIndex({
index,
behavior: 'auto',
align: 'end',
});
}
return;
};
// Hook for keyboard navigation using physical DOM measurements
const useKeyboardNavigation = (
imageNames: string[],
virtuosoRef: React.RefObject<VirtuosoGridHandle>,
rootRef: React.RefObject<HTMLDivElement>,
rangeRef: MutableRefObject<ListRange>
) => {
const dispatch = useAppDispatch();
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
// Get current index of selected image
const currentIndex = useMemo(() => {
if (!lastSelectedImage || imageNames.length === 0) {
return 0;
}
const index = imageNames.findIndex((name) => name === lastSelectedImage);
return index >= 0 ? index : 0;
}, [lastSelectedImage, imageNames]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const rootEl = rootRef.current;
const virtuosoGridHandle = virtuosoRef.current;
const range = rangeRef.current;
if (!rootEl || !virtuosoGridHandle) {
return;
}
if (imageNames.length === 0) {
return;
}
// Only handle arrow keys
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
}
// Don't interfere if user is typing in an input
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
const imagesPerRow = getImagesPerRow(rootEl);
if (imagesPerRow === 0) {
// This can happen if the grid is not yet rendered or has no items
return;
}
event.preventDefault();
let newIndex = currentIndex;
switch (event.key) {
case 'ArrowLeft':
if (currentIndex > 0) {
newIndex = currentIndex - 1;
} else {
// Wrap to last image
newIndex = imageNames.length - 1;
}
break;
case 'ArrowRight':
if (currentIndex < imageNames.length - 1) {
newIndex = currentIndex + 1;
} else {
// Wrap to first image
newIndex = 0;
}
break;
case 'ArrowUp':
// If on first row, stay on current image
if (currentIndex < imagesPerRow) {
newIndex = currentIndex;
} else {
newIndex = Math.max(0, currentIndex - imagesPerRow);
}
break;
case 'ArrowDown':
// If no images below, stay on current image
if (currentIndex >= imageNames.length - imagesPerRow) {
newIndex = currentIndex;
} else {
newIndex = Math.min(imageNames.length - 1, currentIndex + imagesPerRow);
}
break;
}
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) {
const newImageName = imageNames[newIndex];
if (newImageName) {
dispatch(selectionChanged([newImageName]));
scrollIntoView(newIndex, rootEl, virtuosoGridHandle, range);
}
}
},
[rootRef, virtuosoRef, rangeRef, imageNames, currentIndex, dispatch]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
};
const getImageNamesQueryOptions = {
selectFromResult: ({ data, isLoading }) => ({
imageNames: data ?? EMPTY_ARRAY,
isLoading,
}),
} satisfies Parameters<typeof useGetImageNamesQuery>[1];
// Main gallery component
export const NewGallery = memo(() => {
const queryArgs = useDebouncedImageCollectionQueryArgs();
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
// Get the ordered list of image names - this is our primary data source for virtualization
const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions);
// Reset scroll position when query parameters change
useEffect(() => {
if (virtuosoRef.current && imageNames.length > 0) {
virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' });
}
}, [queryArgs, imageNames.length]);
const rootRef = useRef<HTMLDivElement>(null);
// Enable keyboard navigation
useKeyboardNavigation(imageNames, virtuosoRef, rootRef, rangeRef);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
events: {
initialized(osInstance) {
// force overflow styles
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
},
},
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => {
osInstance()?.destroy();
};
}, [scroller, initialize, osInstance]);
// Handle range changes - RTK Query will automatically cache and manage loading
const handleRangeChanged = useCallback((range: ListRange) => {
rangeRef.current = range;
}, []);
const context = useMemo(
() =>
({
imageNames,
queryArgs,
}) satisfies GridContext,
[imageNames, queryArgs]
);
// Item content function
const itemContent: GridItemContent<string, GridContext> = useCallback((index, imageName, ctx) => {
return <ImageAtPosition index={index} imageName={imageName} queryArgs={ctx.queryArgs} />;
}, []);
if (isLoading) {
return (
<Flex height="100%" alignItems="center" justifyContent="center">
<Spinner size="lg" opacity={SPINNER_OPACITY} />
<Text ml={4}>Loading gallery...</Text>
</Flex>
);
}
if (imageNames.length === 0) {
return (
<Flex height="100%" alignItems="center" justifyContent="center">
<Text color="base.300">No images found</Text>
</Flex>
);
}
return (
<Box data-overlayscrollbars-initialize="" ref={rootRef} w="full" h="full">
<VirtuosoGrid<string, GridContext>
ref={virtuosoRef}
context={context}
totalCount={imageNames.length}
data={imageNames}
increaseViewportBy={VIEWPORT_BUFFER}
itemContent={itemContent}
computeItemKey={computeItemKey}
components={components}
style={style}
scrollerRef={setScroller}
scrollSeekConfiguration={scrollSeekConfiguration}
rangeChanged={handleRangeChanged}
/>
</Box>
);
});
NewGallery.displayName = 'NewGallery';
const scrollSeekConfiguration: ScrollSeekConfiguration = {
enter: (velocity) => velocity > SCROLL_SEEK_VELOCITY_THRESHOLD,
exit: (velocity) => velocity === 0,
};
// Styles
const style = { height: '100%', width: '100%' };
// Grid components
const ListComponent: GridComponents<GridContext>['List'] = forwardRef((props, ref) => {
const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth);
return (
<Grid
ref={ref}
gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`}
gap={GRID_GAP}
{...props}
/>
);
});
ListComponent.displayName = 'ListComponent';
const ItemComponent: GridComponents<GridContext>['Item'] = forwardRef((props, ref) => (
<GridItem ref={ref} aspectRatio="1/1" {...props} />
));
ItemComponent.displayName = 'ItemComponent';
const ScrollSeekPlaceholderComponent: GridComponents<GridContext>['ScrollSeekPlaceholder'] = forwardRef(
(props, ref) => (
<GridItem ref={ref} aspectRatio="1/1" {...props}>
<Skeleton w="full" h="full" />
</GridItem>
)
);
ScrollSeekPlaceholderComponent.displayName = 'ScrollSeekPlaceholderComponent';
const components: GridComponents<GridContext> = {
Item: ItemComponent,
List: ListComponent,
ScrollSeekPlaceholder: ScrollSeekPlaceholderComponent,
};

View File

@@ -177,7 +177,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
if (imageDTOs.length === 0 || !lastSelectedImage) {
return 0;
}
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage.image_name);
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage);
}, [imageDTOs, lastSelectedImage]);
const handleNavigation = useCallback(
@@ -187,9 +187,9 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
return;
}
if (alt) {
dispatch(imageToCompareChanged(image));
dispatch(imageToCompareChanged(image.image_name));
} else {
dispatch(imageSelected(image));
dispatch(imageSelected(image.image_name));
}
scrollToImage(image.image_name, index);
},

View File

@@ -199,7 +199,7 @@ export const useImageActions = (imageDTO: ImageDTO | null) => {
if (!imageDTO) {
return;
}
deleteImageModal.delete([imageDTO]);
deleteImageModal.delete([imageDTO.image_name]);
}, [deleteImageModal, imageDTO]);
return {

View File

@@ -4,14 +4,10 @@ import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types';
import type { ListBoardsArgs, ListImagesArgs, SQLiteDirection } from 'services/api/types';
export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0));
export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1));
export const selectLastSelectedImageName = createSelector(
selectGallerySlice,
(gallery) => gallery.selection.at(-1)?.image_name
);
export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit);
export const selectListImagesQueryArgs = createMemoizedSelector(
@@ -42,6 +38,15 @@ export const selectListBoardsQueryArgs = createMemoizedSelector(
export const selectAutoAddBoardId = createSelector(selectGallerySlice, (gallery) => gallery.autoAddBoardId);
export const selectSelectedBoardId = createSelector(selectGallerySlice, (gallery) => gallery.selectedBoardId);
export const selectImageCollectionQueryArgs = createMemoizedSelector(selectGallerySlice, (gallery) => ({
board_id: gallery.selectedBoardId === 'none' ? undefined : gallery.selectedBoardId,
categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
search_term: gallery.searchTerm || undefined,
order_dir: gallery.orderDir as SQLiteDirection,
is_intermediate: false,
starred_first: true,
}));
export const selectAutoAssignBoardOnClick = createSelector(
selectGallerySlice,
(gallery) => gallery.autoAssignBoardOnClick

View File

@@ -1,8 +1,8 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { isEqual, uniqBy } from 'lodash-es';
import type { BoardRecordOrderBy, ImageDTO } from 'services/api/types';
import { isEqual, uniq } from 'lodash-es';
import type { BoardRecordOrderBy } from 'services/api/types';
import type { BoardId, ComparisonMode, GalleryState, GalleryView, OrderDir } from './types';
@@ -33,14 +33,14 @@ export const gallerySlice = createSlice({
name: 'gallery',
initialState: initialGalleryState,
reducers: {
imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
imageSelected: (state, action: PayloadAction<string | null>) => {
// Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent
// unnecessary re-renders of the gallery.
const selectedImage = action.payload;
const selectedImageName = action.payload;
// If we got `null`, clear the selection
if (!selectedImage) {
if (!selectedImageName) {
// But only if we have images selected
if (state.selection.length > 0) {
state.selection = [];
@@ -50,24 +50,24 @@ export const gallerySlice = createSlice({
// If we have multiple images selected, clear the selection and select the new image
if (state.selection.length !== 1) {
state.selection = [selectedImage];
state.selection = [selectedImageName];
return;
}
// If the selected image is different from the current selection, clear the selection and select the new image
if (!isEqual(state.selection[0], selectedImage)) {
state.selection = [selectedImage];
if (!isEqual(state.selection[0], selectedImageName)) {
state.selection = [selectedImageName];
return;
}
// Else we have the same image selected, do nothing
},
selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
selectionChanged: (state, action: PayloadAction<string[]>) => {
// Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent
// unnecessary re-renders of the gallery.
// Remove duplicates from the selection
const newSelection = uniqBy(action.payload, (i) => i.image_name);
const newSelection = uniq(action.payload);
// If the new selection has a different length, update the selection
if (newSelection.length !== state.selection.length) {
@@ -83,7 +83,7 @@ export const gallerySlice = createSlice({
// Else we have the same selection, do nothing
},
imageToCompareChanged: (state, action: PayloadAction<ImageDTO | null>) => {
imageToCompareChanged: (state, action: PayloadAction<string | null>) => {
state.imageToCompare = action.payload;
},
comparisonModeChanged: (state, action: PayloadAction<ComparisonMode>) => {

View File

@@ -1,4 +1,4 @@
import type { BoardRecordOrderBy, ImageCategory, ImageDTO } from 'services/api/types';
import type { BoardRecordOrderBy, ImageCategory } from 'services/api/types';
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other'];
@@ -10,7 +10,7 @@ export type ComparisonFit = 'contain' | 'fill';
export type OrderDir = 'ASC' | 'DESC';
export type GalleryState = {
selection: ImageDTO[];
selection: string[];
shouldAutoSwitch: boolean;
autoAssignBoardOnClick: boolean;
autoAddBoardId: BoardId;
@@ -24,7 +24,7 @@ export type GalleryState = {
orderDir: OrderDir;
searchTerm: string;
alwaysShowImageSizeBadge: boolean;
imageToCompare: ImageDTO | null;
imageToCompare: string | null;
comparisonMode: ComparisonMode;
comparisonFit: ComparisonFit;
shouldShowArchivedBoards: boolean;

View File

@@ -71,9 +71,9 @@ export const setNodeImageFieldImage = (arg: {
dispatch(fieldImageValueChanged({ ...fieldIdentifier, value: imageDTO }));
};
export const setComparisonImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => {
const { imageDTO, dispatch } = arg;
dispatch(imageToCompareChanged(imageDTO));
export const setComparisonImage = (arg: { image_name: string; dispatch: AppDispatch }) => {
const { image_name, dispatch } = arg;
dispatch(imageToCompareChanged(image_name));
};
export const createNewCanvasEntityFromImage = (arg: {
@@ -292,14 +292,14 @@ export const replaceCanvasEntityObjectsWithImage = (arg: {
);
};
export const addImagesToBoard = (arg: { imageDTOs: ImageDTO[]; boardId: BoardId; dispatch: AppDispatch }) => {
const { imageDTOs, boardId, dispatch } = arg;
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false }));
export const addImagesToBoard = (arg: { image_names: string[]; boardId: BoardId; dispatch: AppDispatch }) => {
const { image_names, boardId, dispatch } = arg;
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ image_names, board_id: boardId }, { track: false }));
dispatch(selectionChanged([]));
};
export const removeImagesFromBoard = (arg: { imageDTOs: ImageDTO[]; dispatch: AppDispatch }) => {
const { imageDTOs, dispatch } = arg;
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false }));
export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: AppDispatch }) => {
const { image_names, dispatch } = arg;
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false }));
dispatch(selectionChanged([]));
};

View File

@@ -13,11 +13,13 @@ import { motion } from 'framer-motion';
import type { CSSProperties, PropsWithChildren } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useImageDTO } from 'services/api/endpoints/images';
import { $lastProgressEvent } from 'services/events/stores';
const CurrentImageNode = (props: NodeProps) => {
const imageDTO = useAppSelector(selectLastSelectedImage);
const image_name = useAppSelector(selectLastSelectedImage);
const lastProgressEvent = useStore($lastProgressEvent);
const imageDTO = useImageDTO(image_name);
if (lastProgressEvent?.image) {
return (

View File

@@ -21,10 +21,10 @@ import { Trans, useTranslation } from 'react-i18next';
import { PiFrameCornersBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
type Props = { imageDTO?: ImageDTO };
type Props = { imageDTO: ImageDTO | null; isDisabled: boolean };
export const PostProcessingPopover = memo((props: Props) => {
const { imageDTO } = props;
const { imageDTO, isDisabled } = props;
const dispatch = useAppDispatch();
const postProcessingModel = useAppSelector(selectPostProcessingModel);
const inProgress = useIsQueueMutationInProgress();
@@ -49,6 +49,7 @@ export const PostProcessingPopover = memo((props: Props) => {
aria-label={t('parameters.postProcessing')}
variant="link"
alignSelf="stretch"
isDisabled={isDisabled}
/>
</PopoverTrigger>
<PopoverContent>
@@ -56,7 +57,11 @@ export const PostProcessingPopover = memo((props: Props) => {
<Flex flexDirection="column" gap={4}>
<ParamPostProcessingModel />
{!postProcessingModel && <MissingModelWarning />}
<Button size="sm" isDisabled={!imageDTO || inProgress || !postProcessingModel} onClick={handleClickUpscale}>
<Button
size="sm"
isDisabled={isDisabled || !imageDTO || inProgress || !postProcessingModel}
onClick={handleClickUpscale}
>
{t('parameters.processImage')}
</Button>
</Flex>

View File

@@ -8,6 +8,8 @@ import { parseify } from 'common/util/serialize';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows';
import { $isReadyToEnqueue } from 'features/queue/store/readiness';
import { useAutoLayoutContextSafe } from 'features/ui/layouts/auto-layout-context';
import { VIEWER_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback } from 'react';
import { serializeError } from 'serialize-error';
@@ -17,6 +19,7 @@ const log = logger('generation');
export const useInvoke = () => {
const dispatch = useAppDispatch();
const ctx = useAutoLayoutContextSafe();
const tabName = useAppSelector(selectActiveTab);
const isReady = useStore($isReadyToEnqueue);
const isLocked = useIsWorkflowEditorLocked();
@@ -56,11 +59,21 @@ export const useInvoke = () => {
const enqueueBack = useCallback(() => {
enqueue(false, false);
}, [enqueue]);
if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
ctx?.focusPanel(VIEWER_PANEL_ID);
} else if (tabName === 'canvas') {
ctx?.focusPanel(WORKSPACE_PANEL_ID);
}
}, [ctx, enqueue, tabName]);
const enqueueFront = useCallback(() => {
enqueue(true, false);
}, [enqueue]);
if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
ctx?.focusPanel(VIEWER_PANEL_ID);
} else if (tabName === 'canvas') {
ctx?.focusPanel(WORKSPACE_PANEL_ID);
}
}, [ctx, enqueue, tabName]);
return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady || isLocked, enqueue };
};

View File

@@ -1,3 +1,4 @@
import type { ProgressProps } from '@invoke-ai/ui-library';
import { Progress } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react';
@@ -5,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
import { $isConnected, $lastProgressEvent } from 'services/events/stores';
const ProgressBar = () => {
const ProgressBar = (props: ProgressProps) => {
const { t } = useTranslation();
const { data: queueStatus } = useGetQueueStatusQuery();
const isConnected = useStore($isConnected);
@@ -45,6 +46,7 @@ const ProgressBar = () => {
h={2}
w="full"
colorScheme="invokeBlue"
{...props}
/>
);
};

View File

@@ -1,9 +1,9 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter';
import type { IDockviewPanelHeaderProps } from 'dockview';
import { useCallback, useRef } from 'react';
import { memo, useCallback, useRef } from 'react';
export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => {
export const TabWithoutCloseButton = memo((props: IDockviewPanelHeaderProps) => {
const ref = useRef<HTMLDivElement>(null);
const setActive = useCallback(() => {
if (!props.api.isActive) {
@@ -15,8 +15,10 @@ export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => {
return (
<Flex ref={ref} alignItems="center" h="full">
<Text userSelect="none">{props.api.title ?? props.api.id}</Text>
<Text userSelect="none" px={4}>
{props.api.title ?? props.api.id}
</Text>
</Flex>
);
};
});
TabWithoutCloseButton.displayName = 'TabWithoutCloseButton';

View File

@@ -0,0 +1,31 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter';
import type { IDockviewPanelHeaderProps } from 'dockview';
import ProgressBar from 'features/system/components/ProgressBar';
import { memo, useCallback, useRef } from 'react';
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
export const TabWithoutCloseButtonAndWithProgressIndicator = memo((props: IDockviewPanelHeaderProps) => {
const isGenerationInProgress = useIsGenerationInProgress();
const ref = useRef<HTMLDivElement>(null);
const setActive = useCallback(() => {
if (!props.api.isActive) {
props.api.setActive();
}
}, [props.api]);
useCallbackOnDragEnter(setActive, ref, 300);
return (
<Flex ref={ref} position="relative" alignItems="center" h="full">
<Text userSelect="none" px={4}>
{props.api.title ?? props.api.id}
</Text>
{isGenerationInProgress && (
<ProgressBar position="absolute" bottom={0} left={0} right={0} h={1} borderRadius="none" />
)}
</Flex>
);
});
TabWithoutCloseButtonAndWithProgressIndicator.displayName = 'TabWithoutCloseButtonAndWithProgressIndicator';

View File

@@ -5,20 +5,14 @@ import { atom } from 'nanostores';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useCallback, useContext, useMemo, useState } from 'react';
import {
LEFT_PANEL_ID,
LEFT_PANEL_MIN_SIZE_PX,
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
VIEWER_PANEL_ID,
} from './shared';
import { LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX } from './shared';
type AutoLayoutContextValue = {
toggleLeftPanel: () => void;
toggleRightPanel: () => void;
toggleBothPanels: () => void;
resetPanels: () => void;
focusImageViewer: () => void;
focusPanel: (id: string) => void;
_$rootPanelApi: WritableAtom<GridviewApi | null>;
_$leftPanelApi: WritableAtom<GridviewApi | null>;
_$centerPanelApi: WritableAtom<DockviewApi | null>;
@@ -116,13 +110,16 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable
expandPanel(api, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX);
}, [$rootApi]);
const focusImageViewer = useCallback(() => {
const api = $centerApi.get();
if (!api) {
return;
}
activatePanel(api, VIEWER_PANEL_ID);
}, [$centerApi]);
const focusPanel = useCallback(
(id: string) => {
const api = $centerApi.get();
if (!api) {
return;
}
activatePanel(api, id);
},
[$centerApi]
);
const value = useMemo<AutoLayoutContextValue>(
() => ({
@@ -130,7 +127,7 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable
toggleRightPanel,
toggleBothPanels,
resetPanels,
focusImageViewer,
focusPanel,
_$rootPanelApi: $rootApi,
_$leftPanelApi: $leftApi,
_$centerPanelApi: $centerApi,
@@ -141,7 +138,7 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable
$leftApi,
$rightApi,
$rootApi,
focusImageViewer,
focusPanel,
resetPanels,
toggleBothPanels,
toggleLeftPanel,
@@ -159,6 +156,11 @@ export const useAutoLayoutContext = () => {
return value;
};
export const useAutoLayoutContextSafe = () => {
const value = useContext(AutoLayoutContext);
return value;
};
export const PanelHotkeysLogical = memo(() => {
const { toggleBothPanels, resetPanels, toggleLeftPanel, toggleRightPanel } = useAutoLayoutContext();
useRegisteredHotkeys({

View File

@@ -18,6 +18,7 @@ import { CanvasTabLeftPanel } from './CanvasTabLeftPanel';
import { CanvasWorkspacePanel } from './CanvasWorkspacePanel';
import {
BOARDS_PANEL_ID,
DEFAULT_TAB_ID,
GALLERY_PANEL_ID,
LAUNCHPAD_PANEL_ID,
LAYERS_PANEL_ID,
@@ -28,11 +29,18 @@ import {
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SETTINGS_PANEL_ID,
TAB_WITH_PROGRESS_INDICATOR_ID,
VIEWER_PANEL_ID,
WORKSPACE_PANEL_ID,
} from './shared';
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
const tabComponents = {
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
};
const centerPanelComponents: IDockviewReactProps['components'] = {
[LAUNCHPAD_PANEL_ID]: CanvasLaunchpadPanel,
[WORKSPACE_PANEL_ID]: CanvasWorkspacePanel,
@@ -45,11 +53,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: DEFAULT_TAB_ID,
});
api.addPanel({
id: WORKSPACE_PANEL_ID,
component: WORKSPACE_PANEL_ID,
title: 'Canvas',
tabComponent: DEFAULT_TAB_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
@@ -59,15 +69,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
@@ -107,10 +109,10 @@ const CenterPanel = memo(() => {
locked={true}
disableFloatingGroups={true}
dndEdges={false}
defaultTabComponent={TabWithoutCloseButton}
components={centerPanelComponents}
onReady={onReady}
theme={dockviewTheme}
tabComponents={tabComponents}
/>
<FloatingCanvasLeftPanelButtons />
<FloatingRightPanelButtons />

View File

@@ -16,6 +16,7 @@ import { memo, useCallback, useRef, useState } from 'react';
import { GenerateTabLeftPanel } from './GenerateTabLeftPanel';
import {
BOARDS_PANEL_ID,
DEFAULT_TAB_ID,
GALLERY_PANEL_ID,
LAUNCHPAD_PANEL_ID,
LEFT_PANEL_ID,
@@ -25,10 +26,17 @@ import {
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SETTINGS_PANEL_ID,
TAB_WITH_PROGRESS_INDICATOR_ID,
VIEWER_PANEL_ID,
} from './shared';
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
const tabComponents = {
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
};
const centerPanelComponents: IDockviewReactProps['components'] = {
[LAUNCHPAD_PANEL_ID]: GenerateLaunchpadPanel,
[VIEWER_PANEL_ID]: ImageViewerPanel,
@@ -40,20 +48,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: DEFAULT_TAB_ID,
});
api.addPanel({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
@@ -93,7 +94,7 @@ const CenterPanel = memo(() => {
locked={true}
disableFloatingGroups={true}
dndEdges={false}
defaultTabComponent={TabWithoutCloseButton}
tabComponents={tabComponents}
components={centerPanelComponents}
onReady={onReady}
theme={dockviewTheme}

View File

@@ -13,5 +13,8 @@ export const LAYERS_PANEL_ID = 'layers';
export const SETTINGS_PANEL_ID = 'settings';
export const DEFAULT_TAB_ID = 'default-tab';
export const TAB_WITH_PROGRESS_INDICATOR_ID = 'tab-with-progress-indicator';
export const LEFT_PANEL_MIN_SIZE_PX = 420;
export const RIGHT_PANEL_MIN_SIZE_PX = 420;

View File

@@ -15,6 +15,7 @@ import { memo, useCallback, useRef, useState } from 'react';
import {
BOARDS_PANEL_ID,
DEFAULT_TAB_ID,
GALLERY_PANEL_ID,
LAUNCHPAD_PANEL_ID,
LEFT_PANEL_ID,
@@ -24,11 +25,18 @@ import {
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SETTINGS_PANEL_ID,
TAB_WITH_PROGRESS_INDICATOR_ID,
VIEWER_PANEL_ID,
} from './shared';
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
import { UpscalingTabLeftPanel } from './UpscalingTabLeftPanel';
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
const tabComponents = {
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
};
const centerComponents: IDockviewReactProps['components'] = {
[LAUNCHPAD_PANEL_ID]: UpscalingLaunchpadPanel,
[VIEWER_PANEL_ID]: ImageViewerPanel,
@@ -40,20 +48,13 @@ const initializeCenterLayout = (api: DockviewApi) => {
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: DEFAULT_TAB_ID,
});
api.addPanel({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
@@ -92,7 +93,7 @@ const CenterPanel = memo(() => {
locked={true}
disableFloatingGroups={true}
dndEdges={false}
defaultTabComponent={TabWithoutCloseButton}
tabComponents={tabComponents}
components={centerComponents}
onReady={onReady}
theme={dockviewTheme}

View File

@@ -17,6 +17,7 @@ import { memo, useCallback, useRef, useState } from 'react';
import {
BOARDS_PANEL_ID,
DEFAULT_TAB_ID,
GALLERY_PANEL_ID,
LAUNCHPAD_PANEL_ID,
LEFT_PANEL_ID,
@@ -26,11 +27,18 @@ import {
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SETTINGS_PANEL_ID,
TAB_WITH_PROGRESS_INDICATOR_ID,
VIEWER_PANEL_ID,
WORKSPACE_PANEL_ID,
} from './shared';
import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator';
import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible';
const tabComponents = {
[DEFAULT_TAB_ID]: TabWithoutCloseButton,
[TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator,
};
const centerPanelComponents: IDockviewReactProps['components'] = {
[LAUNCHPAD_PANEL_ID]: WorkflowsLaunchpadPanel,
[WORKSPACE_PANEL_ID]: NodeEditor,
@@ -43,11 +51,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: DEFAULT_TAB_ID,
});
api.addPanel({
id: WORKSPACE_PANEL_ID,
component: WORKSPACE_PANEL_ID,
title: 'Workflow Editor',
tabComponent: DEFAULT_TAB_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
@@ -57,15 +67,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => {
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
},
});
api.addPanel({
id: PROGRESS_PANEL_ID,
component: PROGRESS_PANEL_ID,
title: 'Generation Progress',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
position: {
direction: 'within',
referencePanel: LAUNCHPAD_PANEL_ID,
@@ -105,7 +107,7 @@ const CenterPanel = memo(() => {
locked={true}
disableFloatingGroups={true}
dndEdges={false}
defaultTabComponent={TabWithoutCloseButton}
tabComponents={tabComponents}
components={centerPanelComponents}
onReady={onReady}
theme={dockviewTheme}

View File

@@ -57,8 +57,9 @@
.dv-tab {
/* margin-right: 2px; */
padding-inline-start: var(--invoke-sizes-4);
padding-inline-end: var(--invoke-sizes-4);
/* padding-inline-start: var(--invoke-sizes-4);
padding-inline-end: var(--invoke-sizes-4); */
padding: 0px;
}
.dv-inactive-group .dv-tabs-container.dv-horizontal .dv-tab:not(:first-child)::before {

View File

@@ -1,21 +1,20 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { $authToken } from 'app/store/nanostores/authToken';
import { getStore } from 'app/store/nanostores/store';
import type { BoardId } from 'features/gallery/store/types';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { uniqBy } from 'lodash-es';
import type { components, paths } from 'services/api/schema';
import type {
DeleteBoardResult,
GraphAndWorkflowResponse,
ImageCategory,
ImageDTO,
ImageUploadEntryRequest,
ImageUploadEntryResponse,
ListImagesArgs,
ListImagesResponse,
SQLiteDirection,
UploadImageArg,
} from 'services/api/types';
import { getCategories, getListImagesUrl } from 'services/api/util';
import stableHash from 'stable-hash';
import type { Param0 } from 'tsafe';
import type { JsonObject } from 'type-fest';
@@ -29,7 +28,8 @@ import { buildBoardsUrl } from './boards';
* buildImagesUrl('some-path')
* // '/api/v1/images/some-path'
*/
const buildImagesUrl = (path: string = '') => buildV1Url(`images/${path}`);
const buildImagesUrl = (path: string = '', query?: Parameters<typeof buildV1Url>[1]) =>
buildV1Url(`images/${path}`, query);
/**
* Builds an endpoint URL for the board_images router
@@ -50,10 +50,10 @@ export const imagesApi = api.injectEndpoints({
url: getListImagesUrl(queryArgs),
method: 'GET',
}),
providesTags: (result, error, { board_id, categories }) => {
providesTags: (result, error, queryArgs) => {
return [
// Make the tags the same as the cache key
{ type: 'ImageList', id: getListImagesUrl({ board_id, categories }) },
{ type: 'ImageList', id: JSON.stringify(queryArgs) },
'FetchOnReconnect',
];
},
@@ -80,7 +80,12 @@ export const imagesApi = api.injectEndpoints({
}),
clearIntermediates: build.mutation<number, void>({
query: () => ({ url: buildImagesUrl('intermediates'), method: 'DELETE' }),
invalidatesTags: ['IntermediatesCount', 'InvocationCacheStatus'],
invalidatesTags: [
'IntermediatesCount',
'InvocationCacheStatus',
'ImageCollectionCounts',
{ type: 'ImageCollection', id: LIST_TAG },
],
}),
getImageDTO: build.query<ImageDTO, string>({
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}`) }),
@@ -94,141 +99,89 @@ export const imagesApi = api.injectEndpoints({
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}/workflow`) }),
providesTags: (result, error, image_name) => [{ type: 'ImageWorkflow', id: image_name }],
}),
deleteImage: build.mutation<void, ImageDTO>({
deleteImage: build.mutation<
paths['/api/v1/images/i/{image_name}']['delete']['responses']['200']['content']['application/json'],
paths['/api/v1/images/i/{image_name}']['delete']['parameters']['path']
>({
query: ({ image_name }) => ({
url: buildImagesUrl(`i/${image_name}`),
method: 'DELETE',
}),
invalidatesTags: (result, error, imageDTO) => {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
invalidatesTags: (result) => {
if (!result) {
return [];
}
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
// will force those queries to re-fetch, and the requests will of course 404.
return [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
},
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
'ImageCollectionCounts',
{ type: 'ImageCollection', id: LIST_TAG },
];
},
}),
deleteImages: build.mutation<components['schemas']['DeleteImagesFromListResult'], { imageDTOs: ImageDTO[] }>({
query: ({ imageDTOs }) => {
const image_names = imageDTOs.map((imageDTO) => imageDTO.image_name);
return {
url: buildImagesUrl('delete'),
method: 'POST',
body: {
image_names,
},
};
},
invalidatesTags: (result, error, { imageDTOs }) => {
const tags: ApiTagDescription[] = [];
for (const imageDTO of imageDTOs) {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
tags.push(
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
}
);
deleteImages: build.mutation<
paths['/api/v1/images/delete']['post']['responses']['200']['content']['application/json'],
paths['/api/v1/images/delete']['post']['requestBody']['content']['application/json']
>({
query: (body) => ({
url: buildImagesUrl('delete'),
method: 'POST',
body,
}),
invalidatesTags: (result) => {
if (!result) {
return [];
}
const dedupedTags = uniqBy(tags, stableHash);
return dedupedTags;
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
// will force those queries to re-fetch, and the requests will of course 404.
return [
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
'ImageCollectionCounts',
{ type: 'ImageCollection', id: LIST_TAG },
];
},
}),
deleteUncategorizedImages: build.mutation<components['schemas']['DeleteImagesFromListResult'], void>({
deleteUncategorizedImages: build.mutation<
paths['/api/v1/images/uncategorized']['delete']['responses']['200']['content']['application/json'],
void
>({
query: () => ({ url: buildImagesUrl('uncategorized'), method: 'DELETE' }),
invalidatesTags: (result) => {
if (result && result.deleted_images.length > 0) {
const boardId = 'none';
const tags: ApiTagDescription[] = [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories: IMAGE_CATEGORIES,
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories: ASSETS_CATEGORIES,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
},
];
return tags;
if (!result) {
return [];
}
return [];
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
// will force those queries to re-fetch, and the requests will of course 404.
return [
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
'ImageCollectionCounts',
{ type: 'ImageCollection', id: LIST_TAG },
];
},
}),
/**
* Change an image's `is_intermediate` property.
*/
changeImageIsIntermediate: build.mutation<ImageDTO, { imageDTO: ImageDTO; is_intermediate: boolean }>({
query: ({ imageDTO, is_intermediate }) => ({
url: buildImagesUrl(`i/${imageDTO.image_name}`),
changeImageIsIntermediate: build.mutation<
paths['/api/v1/images/i/{image_name}']['patch']['responses']['200']['content']['application/json'],
{ image_name: string; is_intermediate: boolean }
>({
query: ({ image_name, is_intermediate }) => ({
url: buildImagesUrl(`i/${image_name}`),
method: 'PATCH',
body: { is_intermediate },
}),
invalidatesTags: (result, error, { imageDTO }) => {
const categories = getCategories(imageDTO);
const boardId = imageDTO.board_id ?? 'none';
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [
{ type: 'Image', id: imageDTO.image_name },
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
{
type: 'BoardImagesTotal',
id: boardId,
},
...getTagsToInvalidateForImageMutation([result.image_name]),
...getTagsToInvalidateForBoardAffectingMutation([result.board_id ?? 'none']),
];
},
}),
@@ -236,38 +189,25 @@ export const imagesApi = api.injectEndpoints({
* Star a list of images.
*/
starImages: build.mutation<
paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'],
{ imageDTOs: ImageDTO[] }
paths['/api/v1/images/star']['post']['responses']['200']['content']['application/json'],
paths['/api/v1/images/star']['post']['requestBody']['content']['application/json']
>({
query: ({ imageDTOs: images }) => ({
query: (body) => ({
url: buildImagesUrl('star'),
method: 'POST',
body: { image_names: images.map((img) => img.image_name) },
body,
}),
invalidatesTags: (result, error, { imageDTOs }) => {
// assume all images are on the same board/category
if (imageDTOs[0]) {
const categories = getCategories(imageDTOs[0]);
const boardId = imageDTOs[0].board_id ?? 'none';
const tags: ApiTagDescription[] = [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
];
for (const imageDTO of imageDTOs) {
tags.push({ type: 'Image', id: imageDTO.image_name });
}
return tags;
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [];
return [
...getTagsToInvalidateForImageMutation(result.starred_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
'ImageCollectionCounts',
{ type: 'ImageCollection', id: 'starred' },
{ type: 'ImageCollection', id: 'unstarred' },
];
},
}),
/**
@@ -275,40 +215,30 @@ export const imagesApi = api.injectEndpoints({
*/
unstarImages: build.mutation<
paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'],
{ imageDTOs: ImageDTO[] }
paths['/api/v1/images/unstar']['post']['requestBody']['content']['application/json']
>({
query: ({ imageDTOs: images }) => ({
query: (body) => ({
url: buildImagesUrl('unstar'),
method: 'POST',
body: { image_names: images.map((img) => img.image_name) },
body,
}),
invalidatesTags: (result, error, { imageDTOs }) => {
// assume all images are on the same board/category
if (imageDTOs[0]) {
const categories = getCategories(imageDTOs[0]);
const boardId = imageDTOs[0].board_id ?? 'none';
const tags: ApiTagDescription[] = [
{
type: 'ImageList',
id: getListImagesUrl({
board_id: boardId,
categories,
}),
},
{
type: 'Board',
id: boardId,
},
];
for (const imageDTO of imageDTOs) {
tags.push({ type: 'Image', id: imageDTO.image_name });
}
return tags;
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [];
return [
...getTagsToInvalidateForImageMutation(result.unstarred_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
'ImageCollectionCounts',
{ type: 'ImageCollection', id: 'starred' },
{ type: 'ImageCollection', id: 'unstarred' },
];
},
}),
uploadImage: build.mutation<ImageDTO, UploadImageArg>({
uploadImage: build.mutation<
paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'],
UploadImageArg
>({
query: ({ file, image_category, is_intermediate, session_id, board_id, crop_visible, metadata, resize_to }) => {
const formData = new FormData();
formData.append('file', file);
@@ -366,8 +296,11 @@ export const imagesApi = api.injectEndpoints({
body: { width, height, board_id },
}),
}),
deleteBoard: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }),
deleteBoard: build.mutation<
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'],
paths['/api/v1/boards/{board_id}']['delete']['parameters']['path']
>({
query: ({ board_id }) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }),
invalidatesTags: () => [
{ type: 'Board', id: LIST_TAG },
// invalidate the 'No Board' cache
@@ -388,192 +321,95 @@ export const imagesApi = api.injectEndpoints({
],
}),
deleteBoardAndImages: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({
deleteBoardAndImages: build.mutation<
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'],
paths['/api/v1/boards/{board_id}']['delete']['parameters']['path']
>({
query: ({ board_id }) => ({
url: buildBoardsUrl(board_id),
method: 'DELETE',
params: { include_images: true },
}),
invalidatesTags: () => [{ type: 'Board', id: LIST_TAG }],
}),
addImageToBoard: build.mutation<void, { board_id: BoardId; imageDTO: ImageDTO }>({
query: ({ board_id, imageDTO }) => {
const { image_name } = imageDTO;
addImageToBoard: build.mutation<
paths['/api/v1/board_images/']['post']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json']
>({
query: (body) => {
return {
url: buildBoardImagesUrl(),
method: 'POST',
body: { board_id, image_name },
body,
};
},
invalidatesTags: (result, error, { board_id, imageDTO }) => {
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [
{ type: 'Image', id: imageDTO.image_name },
{
type: 'ImageList',
id: getListImagesUrl({
board_id,
categories: getCategories(imageDTO),
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTO.board_id ?? 'none',
categories: getCategories(imageDTO),
}),
},
{ type: 'Board', id: board_id },
{ type: 'Board', id: imageDTO.board_id ?? 'none' },
{
type: 'BoardImagesTotal',
id: imageDTO.board_id ?? 'none',
},
{
type: 'BoardImagesTotal',
id: board_id,
},
...getTagsToInvalidateForImageMutation(result.added_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
removeImageFromBoard: build.mutation<void, { imageDTO: ImageDTO }>({
query: ({ imageDTO }) => {
const { image_name } = imageDTO;
removeImageFromBoard: build.mutation<
paths['/api/v1/board_images/']['delete']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json']
>({
query: (body) => {
return {
url: buildBoardImagesUrl(),
method: 'DELETE',
body: { image_name },
body,
};
},
invalidatesTags: (result, error, { imageDTO }) => {
invalidatesTags: (result) => {
if (!result) {
return [];
}
return [
{ type: 'Image', id: imageDTO.image_name },
{
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTO.board_id,
categories: getCategories(imageDTO),
}),
},
{
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: getCategories(imageDTO),
}),
},
{ type: 'Board', id: imageDTO.board_id ?? 'none' },
{ type: 'Board', id: 'none' },
{
type: 'BoardImagesTotal',
id: imageDTO.board_id ?? 'none',
},
{ type: 'BoardImagesTotal', id: 'none' },
...getTagsToInvalidateForImageMutation(result.removed_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
addImagesToBoard: build.mutation<
components['schemas']['AddImagesToBoardResult'],
{
board_id: string;
imageDTOs: ImageDTO[];
}
paths['/api/v1/board_images/batch']['post']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/batch']['post']['requestBody']['content']['application/json']
>({
query: ({ board_id, imageDTOs }) => ({
query: (body) => ({
url: buildBoardImagesUrl('batch'),
method: 'POST',
body: {
image_names: imageDTOs.map((i) => i.image_name),
board_id,
},
body,
}),
invalidatesTags: (result, error, { board_id, imageDTOs }) => {
const tags: ApiTagDescription[] = [];
if (imageDTOs[0]) {
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTOs[0].board_id ?? 'none',
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: board_id,
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({ type: 'Board', id: imageDTOs[0].board_id ?? 'none' });
tags.push({
type: 'BoardImagesTotal',
id: imageDTOs[0].board_id ?? 'none',
});
invalidatesTags: (result) => {
if (!result) {
return [];
}
for (const imageDTO of imageDTOs) {
tags.push({ type: 'Image', id: imageDTO.image_name });
}
tags.push({ type: 'Board', id: board_id });
tags.push({
type: 'BoardImagesTotal',
id: board_id ?? 'none',
});
return tags;
return [
...getTagsToInvalidateForImageMutation(result.added_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
removeImagesFromBoard: build.mutation<
components['schemas']['RemoveImagesFromBoardResult'],
{
imageDTOs: ImageDTO[];
}
paths['/api/v1/board_images/batch/delete']['post']['responses']['201']['content']['application/json'],
paths['/api/v1/board_images/batch/delete']['post']['requestBody']['content']['application/json']
>({
query: ({ imageDTOs }) => ({
query: (body) => ({
url: buildBoardImagesUrl('batch/delete'),
method: 'POST',
body: {
image_names: imageDTOs.map((i) => i.image_name),
},
body,
}),
invalidatesTags: (result, error, { imageDTOs }) => {
const touchedBoardIds: string[] = [];
const tags: ApiTagDescription[] = [];
if (imageDTOs[0]) {
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTOs[0].board_id,
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id: 'none',
categories: getCategories(imageDTOs[0]),
}),
});
tags.push({
type: 'BoardImagesTotal',
id: 'none',
});
invalidatesTags: (result) => {
if (!result) {
return [];
}
result?.removed_image_names.forEach((image_name) => {
const board_id = imageDTOs.find((i) => i.image_name === image_name)?.board_id;
if (!board_id || touchedBoardIds.includes(board_id)) {
tags.push({ type: 'Board', id: 'none' });
return;
}
tags.push({ type: 'Image', id: image_name });
tags.push({ type: 'Board', id: board_id });
tags.push({
type: 'BoardImagesTotal',
id: board_id ?? 'none',
});
});
return tags;
return [
...getTagsToInvalidateForImageMutation(result.removed_images),
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
];
},
}),
bulkDownloadImages: build.mutation<
@@ -589,6 +425,113 @@ export const imagesApi = api.injectEndpoints({
},
}),
}),
/**
* Get counts for starred and unstarred image collections
*/
getImageCollectionCounts: build.query<
paths['/api/v1/images/collections/counts']['get']['responses']['200']['content']['application/json'],
paths['/api/v1/images/collections/counts']['get']['parameters']['query']
>({
query: (queryArgs) => ({
url: buildImagesUrl('collections/counts', queryArgs),
method: 'GET',
}),
providesTags: ['ImageCollectionCounts', 'FetchOnReconnect'],
}),
/**
* Get images from a specific collection (starred or unstarred)
*/
getImageCollection: build.query<
paths['/api/v1/images/collections/{collection}']['get']['responses']['200']['content']['application/json'],
paths['/api/v1/images/collections/{collection}']['get']['parameters']['path'] &
paths['/api/v1/images/collections/{collection}']['get']['parameters']['query']
>({
query: ({ collection, ...queryArgs }) => ({
url: buildImagesUrl(`collections/${collection}`, queryArgs),
method: 'GET',
}),
providesTags: (result, error, { collection, board_id, categories }) => {
const cacheKey = `${collection}-${board_id || 'all'}-${categories?.join(',') || 'all'}`;
return [
{ type: 'ImageCollection', id: collection },
{ type: 'ImageCollection', id: cacheKey },
'FetchOnReconnect',
];
},
async onQueryStarted(_, { dispatch, queryFulfilled }) {
// Populate the getImageDTO cache with these images, similar to listImages
const res = await queryFulfilled;
const imageDTOs = res.data.items;
const updates: Param0<typeof imagesApi.util.upsertQueryEntries> = [];
for (const imageDTO of imageDTOs) {
updates.push({
endpointName: 'getImageDTO',
arg: imageDTO.image_name,
value: imageDTO,
});
}
dispatch(imagesApi.util.upsertQueryEntries(updates));
},
}),
/**
* Get ordered list of image names for selection operations
*/
getImageNames: build.query<
string[],
{
image_origin?: 'internal' | 'external' | null;
categories?: ImageCategory[] | null;
is_intermediate?: boolean | null;
board_id?: string | null;
search_term?: string | null;
order_dir?: SQLiteDirection;
}
>({
query: (queryArgs) => ({
url: buildImagesUrl('names', queryArgs),
method: 'GET',
}),
providesTags: ['ImageNameList', 'FetchOnReconnect'],
}),
/**
* Get paginated images with starred first (unified list)
*/
getUnifiedImageList: build.query<
ListImagesResponse,
{
offset?: number;
limit?: number;
image_origin?: 'internal' | 'external' | null;
categories?: ImageCategory[] | null;
is_intermediate?: boolean | null;
board_id?: string | null;
search_term?: string | null;
order_dir?: SQLiteDirection;
}
>({
query: (queryArgs) => ({
url: getListImagesUrl({ ...queryArgs, starred_first: true }),
method: 'GET',
}),
providesTags: (result, error, { board_id, categories }) => [
{ type: 'ImageList', id: getListImagesUrl({ board_id, categories }) },
'FetchOnReconnect',
],
async onQueryStarted(_, { dispatch, queryFulfilled }) {
// Populate the getImageDTO cache with these images
const res = await queryFulfilled;
const imageDTOs = res.data.items;
const updates: Param0<typeof imagesApi.util.upsertQueryEntries> = [];
for (const imageDTO of imageDTOs) {
updates.push({
endpointName: 'getImageDTO',
arg: imageDTO.image_name,
value: imageDTO,
});
}
dispatch(imagesApi.util.upsertQueryEntries(updates));
},
}),
}),
});
@@ -610,6 +553,11 @@ export const {
useStarImagesMutation,
useUnstarImagesMutation,
useBulkDownloadImagesMutation,
useGetImageCollectionCountsQuery,
useGetImageCollectionQuery,
useLazyGetImageCollectionQuery,
useGetImageNamesQuery,
useGetUnifiedImageListQuery,
} = imagesApi;
/**
@@ -711,3 +659,63 @@ export const imageDTOToFile = async (imageDTO: ImageDTO): Promise<File> => {
const file = new File([blob], `copy_of_${imageDTO.image_name}`, { type: 'image/png' });
return file;
};
export const useImageDTO = (imageName: string | null | undefined) => {
const { data: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
return imageDTO ?? null;
};
export const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => {
const tags: ApiTagDescription[] = [];
for (const image_name of image_names) {
tags.push({
type: 'Image',
id: image_name,
});
tags.push({
type: 'ImageMetadata',
id: image_name,
});
tags.push({
type: 'ImageWorkflow',
id: image_name,
});
}
return tags;
};
export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => {
const tags: ApiTagDescription[] = [];
for (const board_id of affected_boards) {
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id,
categories: IMAGE_CATEGORIES,
}),
});
tags.push({
type: 'ImageList',
id: getListImagesUrl({
board_id,
categories: ASSETS_CATEGORIES,
}),
});
tags.push({
type: 'Board',
id: board_id,
});
tags.push({
type: 'BoardImagesTotal',
id: board_id,
});
}
return tags;
};

View File

@@ -396,3 +396,8 @@ export const selectCanvasQueueCounts = queueApi.endpoints.getQueueCountsByDestin
export const enqueueMutationFixedCacheKeyOptions = {
fixedCacheKey: 'enqueueBatch',
} as const;
export const useIsGenerationInProgress = () => {
const { data } = useGetQueueStatusQuery();
return data && data.queue.in_progress > 0;
};

View File

@@ -10,6 +10,7 @@ import { buildCreateApi, coreModule, fetchBaseQuery, reactHooksModule } from '@r
import { $authToken } from 'app/store/nanostores/authToken';
import { $baseUrl } from 'app/store/nanostores/baseUrl';
import { $projectId } from 'app/store/nanostores/projectId';
import queryString from 'query-string';
const tagTypes = [
'AppVersion',
@@ -23,6 +24,8 @@ const tagTypes = [
'ImageList',
'ImageMetadata',
'ImageWorkflow',
'ImageCollectionCounts',
'ImageCollection',
'ImageMetadataFromFile',
'IntermediatesCount',
'SessionQueueItem',
@@ -131,5 +134,10 @@ function getCircularReplacer() {
};
}
export const buildV1Url = (path: string): string => `api/v1/${path}`;
export const buildV1Url = (path: string, query?: Parameters<typeof queryString.stringify>[0]): string => {
if (!query) {
return `api/v1/${path}`;
}
return `api/v1/${path}?${queryString.stringify(query)}`;
};
export const buildV2Url = (path: string): string => `api/v2/${path}`;

View File

@@ -752,6 +752,46 @@ export type paths = {
patch?: never;
trace?: never;
};
"/api/v1/images/collections/counts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Image Collection Counts
* @description Gets counts for starred and unstarred image collections
*/
get: operations["get_image_collection_counts"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/images/collections/{collection}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Image Collection
* @description Gets images from a specific collection (starred or unstarred)
*/
get: operations["get_image_collection"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/boards/": {
parameters: {
query?: never;
@@ -1781,15 +1821,15 @@ export type components = {
/** AddImagesToBoardResult */
AddImagesToBoardResult: {
/**
* Board Id
* @description The id of the board the images were added to
* Affected Boards
* @description The ids of boards affected by the delete operation
*/
board_id: string;
affected_boards: string[];
/**
* Added Image Names
* Added Images
* @description The image names that were added to the board
*/
added_image_names: string[];
added_images: string[];
};
/**
* Add Integers
@@ -5945,9 +5985,17 @@ export type components = {
*/
deleted: number;
};
/** DeleteImagesFromListResult */
DeleteImagesFromListResult: {
/** Deleted Images */
/** DeleteImagesResult */
DeleteImagesResult: {
/**
* Affected Boards
* @description The ids of boards affected by the delete operation
*/
affected_boards: string[];
/**
* Deleted Images
* @description The names of the images that were deleted
*/
deleted_images: string[];
};
/**
@@ -9791,6 +9839,19 @@ export type components = {
*/
type: "img_channel_offset";
};
/** ImageCollectionCounts */
ImageCollectionCounts: {
/**
* Starred Count
* @description The number of starred images in the collection.
*/
starred_count: number;
/**
* Unstarred Count
* @description The number of unstarred images in the collection.
*/
unstarred_count: number;
};
/**
* Image Collection Primitive
* @description A collection of image primitive values
@@ -11019,14 +11080,6 @@ export type components = {
*/
bulk_download_item_name?: string | null;
};
/** ImagesUpdatedFromListResult */
ImagesUpdatedFromListResult: {
/**
* Updated Image Names
* @description The image names that were updated
*/
updated_image_names: string[];
};
/**
* Solid Color Infill
* @description Infills transparent areas of an image with a solid color
@@ -17798,10 +17851,15 @@ export type components = {
/** RemoveImagesFromBoardResult */
RemoveImagesFromBoardResult: {
/**
* Removed Image Names
* Affected Boards
* @description The ids of boards affected by the delete operation
*/
affected_boards: string[];
/**
* Removed Images
* @description The image names that were removed from their board
*/
removed_image_names: string[];
removed_images: string[];
};
/**
* Resize Latents
@@ -19602,6 +19660,19 @@ export type components = {
*/
type: "spandrel_image_to_image";
};
/** StarredImagesResult */
StarredImagesResult: {
/**
* Affected Boards
* @description The ids of boards affected by the delete operation
*/
affected_boards: string[];
/**
* Starred Images
* @description The names of the images that were starred
*/
starred_images: string[];
};
/** StarterModel */
StarterModel: {
/** Description */
@@ -21207,6 +21278,19 @@ export type components = {
*/
type: "unsharp_mask";
};
/** UnstarredImagesResult */
UnstarredImagesResult: {
/**
* Affected Boards
* @description The ids of boards affected by the delete operation
*/
affected_boards: string[];
/**
* Unstarred Images
* @description The names of the images that were unstarred
*/
unstarred_images: string[];
};
/** Upscaler */
Upscaler: {
/**
@@ -23064,7 +23148,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["DeleteImagesResult"];
};
};
/** @description Validation Error */
@@ -23386,7 +23470,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeleteImagesFromListResult"];
"application/json": components["schemas"]["DeleteImagesResult"];
};
};
/** @description Validation Error */
@@ -23415,7 +23499,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeleteImagesFromListResult"];
"application/json": components["schemas"]["DeleteImagesResult"];
};
};
};
@@ -23439,7 +23523,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImagesUpdatedFromListResult"];
"application/json": components["schemas"]["StarredImagesResult"];
};
};
/** @description Validation Error */
@@ -23472,7 +23556,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImagesUpdatedFromListResult"];
"application/json": components["schemas"]["UnstarredImagesResult"];
};
};
/** @description Validation Error */
@@ -23558,6 +23642,95 @@ export interface operations {
};
};
};
get_image_collection_counts: {
parameters: {
query?: {
/** @description The origin of images to count. */
image_origin?: components["schemas"]["ResourceOrigin"] | null;
/** @description The categories of image to include. */
categories?: components["schemas"]["ImageCategory"][] | null;
/** @description Whether to include intermediate images. */
is_intermediate?: boolean | null;
/** @description The board id to filter by. Use 'none' to find images without a board. */
board_id?: string | null;
/** @description The term to search for */
search_term?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImageCollectionCounts"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_image_collection: {
parameters: {
query?: {
/** @description The origin of images to list. */
image_origin?: components["schemas"]["ResourceOrigin"] | null;
/** @description The categories of image to include. */
categories?: components["schemas"]["ImageCategory"][] | null;
/** @description Whether to list intermediate images. */
is_intermediate?: boolean | null;
/** @description The board id to filter by. Use 'none' to find images without a board. */
board_id?: string | null;
/** @description The offset within the collection */
offset?: number;
/** @description The number of images to return */
limit?: number;
/** @description The order of sort */
order_dir?: components["schemas"]["SQLiteDirection"];
/** @description The term to search for */
search_term?: string | null;
};
header?: never;
path: {
/** @description The collection to retrieve from */
collection: "starred" | "unstarred";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_boards: {
parameters: {
query?: {
@@ -23793,7 +23966,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["AddImagesToBoardResult"];
};
};
/** @description Validation Error */
@@ -23826,7 +23999,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["RemoveImagesFromBoardResult"];
};
};
/** @description Validation Error */

View File

@@ -117,7 +117,7 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi
);
} else {
// Else just select the image, no need to switch boards
dispatch(imageSelected(lastImageDTO));
dispatch(imageSelected(lastImageDTO.image_name));
if (galleryView !== 'images') {
// We also need to update the gallery view to images. This also updates the offset.

View File

@@ -21,10 +21,12 @@ export const $lastProgressMessage = computed($lastProgressEvent, (val) => {
if (!val) {
return null;
}
let message = val.message;
if (val.percentage) {
message += ` (${round(val.percentage * 100)}%)`;
return formatProgressMessage(val);
});
export const formatProgressMessage = (data: S['InvocationProgressEvent']): string => {
let message = data.message;
if (data.percentage) {
message += ` (${round(data.percentage * 100)}%)`;
}
return message;
});
};

View File

@@ -1 +1 @@
__version__ = "6.0.0a4"
__version__ = "6.0.0a6"