Compare commits

..

103 Commits

Author SHA1 Message Date
psychedelicious
ab4dec4aa6 chore(ui): lint 2025-06-16 19:02:01 +10:00
psychedelicious
3ae6714188 feat(ui): rework simple session initial state 2025-06-16 18:59:01 +10:00
psychedelicious
0b461fa8da fix(ui): invoke button tooltip on generate tab 2025-06-16 18:27:29 +10:00
psychedelicious
26a7e342c3 fix(ui): progress image fixes 2025-06-16 18:25:37 +10:00
psychedelicious
55e5f8be1b 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-16 17:41:47 +10:00
psychedelicious
5a65121247 feat(ui): refine ref images UI 2025-06-16 17:33:17 +10:00
psychedelicious
c2fe280c38 feat(ui): toggleable negative prompt 2025-06-16 17:03:19 +10:00
psychedelicious
e7dee83dad fix(ui): remove old isSelected from refImageAdded call 2025-06-16 16:34:29 +10:00
psychedelicious
3bf08b6d88 chore: bump version to v6.0.0a2 2025-06-13 21:22:30 +10:00
psychedelicious
f821bc30a0 fix(ui): update queue item preview images on init of queue items context 2025-06-13 17:07:50 +10:00
psychedelicious
bc6f493931 fix(ui): hack to close chakra tooltips on drag 2025-06-13 17:07:42 +10:00
psychedelicious
fa332d1a56 tweak(ui): ref image header 2025-06-13 15:39:57 +10:00
psychedelicious
4452ddf6c6 experiment(ui): add generate tab 2025-06-13 15:38:53 +10:00
psychedelicious
5dec79fde5 refactor(ui): ref images (WIP) 2025-06-13 15:19:02 +10:00
psychedelicious
df833d7563 refactor(ui): ref images (WIP) 2025-06-13 13:08:03 +10:00
psychedelicious
c2556f99bc refactor(ui): refImage.ipAdapter -> refImage.config 2025-06-13 12:22:02 +10:00
psychedelicious
391b883a87 feat(ui): split out ref images into own slice (WIP) 2025-06-12 17:18:06 +10:00
psychedelicious
9a06ffe3f5 feat(ui): simple session initial state cards are buttons 2025-06-11 12:47:46 +10:00
psychedelicious
548273643e chore(ui): dpdm 2025-06-11 12:34:01 +10:00
psychedelicious
d158027565 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-11 12:34:01 +10:00
psychedelicious
419773cde0 fix(ui): use imageDTO in staging area 2025-06-11 12:34:01 +10:00
psychedelicious
e64075b913 fix(ui): wait until last queue item deleted before flagging canvas session finished 2025-06-11 12:34:01 +10:00
psychedelicious
c9e48fc195 feat(ui): store output image DTO in session context instead of just the name 2025-06-11 12:34:01 +10:00
psychedelicious
67b11d3e0c feat(ui): add AppGetState type 2025-06-11 12:34:01 +10:00
psychedelicious
b782d8c7cd chore: bump version to v6.0.0a1 2025-06-11 12:34:01 +10:00
psychedelicious
d34256b788 feat(ui): close viewer on escape 2025-06-11 12:33:48 +10:00
psychedelicious
4c9553af51 fix(ui): switch only on first progress image 2025-06-11 12:33:48 +10:00
psychedelicious
dbe7fbea2e feat(ui): add on first progress autoswitch mode 2025-06-11 12:33:48 +10:00
psychedelicious
10ba402437 feat(ui): move canvas-specific staging subscriptions to CanvasStagingAreaModule 2025-06-11 12:33:48 +10:00
psychedelicious
25c67f0c68 chore(ui): lint 2025-06-11 12:33:48 +10:00
psychedelicious
144485aa0b feat(ui): make main panel styling and title consistent 2025-06-11 12:33:48 +10:00
psychedelicious
b4c10509f5 feat(ui): add startover button to canvas toolbar 2025-06-11 12:33:48 +10:00
psychedelicious
1cd4e23072 feat(ui): fiddle w/ staging area header 2025-06-11 12:33:48 +10:00
psychedelicious
8f23c4513d feat(ui): remove technical progress message from full preview 2025-06-11 12:33:48 +10:00
psychedelicious
a430872e60 feat(ui): simple session initial state 2025-06-11 12:33:48 +10:00
psychedelicious
af838e8ebb feat(ui): remove vary and edit as control buttons 2025-06-11 12:33:48 +10:00
psychedelicious
7f5fdcd54c refactor(ui): migrate from canceling queue items to deleteing, make queue hook APIs consistent 2025-06-11 12:33:48 +10:00
psychedelicious
ba5fd32f20 fix(ui): mini preview bg color 2025-06-11 12:33:47 +10:00
psychedelicious
9f3d09dc01 fix(ui): hide layers when not on canvas tab 2025-06-11 12:33:47 +10:00
psychedelicious
081942b72e build(ui): temporarily ignore all knip issues 2025-06-11 12:33:47 +10:00
psychedelicious
2b54b32740 feat(ui): finish generation when discarding last item 2025-06-11 12:33:47 +10:00
psychedelicious
1145d67d0d feat(ui): when discarding last item, select new last instead of first 2025-06-11 12:33:47 +10:00
psychedelicious
3d0dd13d8c feat(ui): tweak staging image display 2025-06-11 12:33:47 +10:00
psychedelicious
efb28d55a2 feat(ui): add staging area toolbar to simple session 2025-06-11 12:33:47 +10:00
psychedelicious
e41050359f fix(ui): ensure canvas tool modules are destroyed 2025-06-11 12:33:47 +10:00
psychedelicious
667ed6ab09 fix(ui): reset layers when changing session type 2025-06-11 12:33:47 +10:00
psychedelicious
f5ad063253 feat(ui): improved staging placeholders 2025-06-11 12:33:47 +10:00
psychedelicious
ef2324d72a feat(ui): improved staging placeholders 2025-06-11 12:33:47 +10:00
psychedelicious
26a01d544f feat(ui): more staging fixes 2025-06-11 12:33:46 +10:00
psychedelicious
1f5572cf75 feat(ui): update canvas session state handling for new staging strat 2025-06-11 12:33:46 +10:00
psychedelicious
ad137cdc33 chore(ui): lint (partial cleanup) 2025-06-11 12:33:46 +10:00
psychedelicious
250a834f44 feat(ui): rough out canvas staging area 2025-06-11 12:33:46 +10:00
psychedelicious
0cb3a7c654 feat(app): support deleting queue items by id or destination 2025-06-11 12:33:46 +10:00
psychedelicious
34460984a9 feat(ui): tweak canvas scroll to zoom feel 2025-06-11 12:33:46 +10:00
psychedelicious
4d628c10db docs(ui): add comment about auto-switch not being quite right yet 2025-06-11 12:33:46 +10:00
psychedelicious
f240f1a5d0 feat: canvas flow rework (wip) 2025-06-11 12:33:46 +10:00
psychedelicious
88d2878a11 feat(ui): prevent flicker of image action buttons 2025-06-11 12:33:46 +10:00
psychedelicious
0df8ab51ee feat(ui): move socket events handling into ctx component 2025-06-11 12:33:46 +10:00
psychedelicious
f66f2b3c71 feat(ui): modularize all staging area logic so it can be shared w/ canvas more easily 2025-06-11 12:33:46 +10:00
psychedelicious
f9366ffeff perf(ui): queue actions menu is lazy 2025-06-11 12:33:45 +10:00
psychedelicious
d7fc9604f2 fix(ui): cursor on staging area preview image 2025-06-11 12:33:45 +10:00
psychedelicious
cbda3f1c86 feat(ui): remove clear queue ui components 2025-06-11 12:33:45 +10:00
psychedelicious
973b2a9b45 feat(app): do not prune queue on startup
With the new canvas design, this will result in loss of staging area images.
2025-06-11 12:33:45 +10:00
psychedelicious
5bea0cd431 tidy(ui): component organization 2025-06-11 12:33:45 +10:00
psychedelicious
7a01278537 fix(ui): prevent drag of progress images 2025-06-11 12:33:45 +10:00
psychedelicious
ea42d08bc2 feat: canvas flow rework (wip) 2025-06-11 12:33:45 +10:00
psychedelicious
4d3089f870 feat: canvas flow rework (wip) 2025-06-11 12:33:45 +10:00
psychedelicious
ebd88f59ad chore(ui): typegen 2025-06-11 12:33:45 +10:00
psychedelicious
cce66d90cc feat(api): remove status from list all queue items query 2025-06-11 12:33:45 +10:00
psychedelicious
67c1f900bb tidy(ui): app layout components 2025-06-11 12:33:44 +10:00
psychedelicious
8df45ce671 feat: canvas flow rework (wip) 2025-06-11 12:33:44 +10:00
psychedelicious
cc411fd244 feat: canvas flow rework (wip) 2025-06-11 12:33:44 +10:00
psychedelicious
eae40cae2b feat: canvas flow rework (wip) 2025-06-11 12:33:44 +10:00
psychedelicious
1e739dc003 fix(ui): unstable selector results in lora drop down 2025-06-11 12:33:44 +10:00
psychedelicious
ea63e16b69 feat: canvas flow rework (wip) 2025-06-11 12:33:44 +10:00
psychedelicious
6923a23f31 feat: canvas flow rework (wip) 2025-06-11 12:33:44 +10:00
psychedelicious
cb0e6da5cf wip progress events 2025-06-11 12:33:44 +10:00
psychedelicious
ae35d67c9a refactor(ui): canvas flow (wip) 2025-06-11 12:33:44 +10:00
psychedelicious
7174768152 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-11 12:33:44 +10:00
psychedelicious
d750a2c6c0 fix(ui): merge refs when forwardingin DndImage 2025-06-11 12:33:44 +10:00
psychedelicious
41eafcf47a fix(ui): remove unused sessionId field from type 2025-06-11 12:33:44 +10:00
psychedelicious
4bcb24eb82 fix(ui): ensure all args are passed to handler when creating new canvas from image 2025-06-11 12:33:43 +10:00
psychedelicious
926c29b91d feat(ui): bookmark new inpaint masks 2025-06-11 12:33:43 +10:00
psychedelicious
8dad22ef93 feat(ui): support bookmarking an entity when adding it 2025-06-11 12:33:43 +10:00
psychedelicious
172142ce03 fix(ui): ensure images are added to gallery in simple sessions 2025-06-11 12:33:43 +10:00
psychedelicious
dc31eaa3f9 feat(ui): images always added to gallery in simple session 2025-06-11 12:33:43 +10:00
psychedelicious
19371d70fe wip 2025-06-11 12:33:43 +10:00
psychedelicious
d8d69891c8 refactor(ui): canvas flow (wip) 2025-06-11 12:33:43 +10:00
psychedelicious
168875327b refactor(ui): canvas flow (wip) 2025-06-11 12:33:43 +10:00
psychedelicious
c7fb3d3906 refactor(ui): canvas flow events (wip) 2025-06-11 12:33:43 +10:00
psychedelicious
5aa5ca13ec refactor(ui): canvas flow (wip) 2025-06-11 12:33:43 +10:00
psychedelicious
eb9edff186 refactor(ui): canvas flow (wip) 2025-06-11 12:33:43 +10:00
psychedelicious
839c2e376a refactor(ui): canvas flow (wip) 2025-06-11 12:33:43 +10:00
psychedelicious
1ba3e85e68 refactor(ui): canvas flow (wip) 2025-06-11 12:33:42 +10:00
psychedelicious
28ee1d911a fix(ui): circular import issue 2025-06-11 12:33:42 +10:00
psychedelicious
74a2cb7b77 refactor(ui): params state zodification 2025-06-11 12:33:42 +10:00
psychedelicious
e139158a81 refactor(ui): move params state to big file of canvas zod stuff 2025-06-11 12:33:42 +10:00
psychedelicious
2b383de39c refactor(ui): zod-ify params slice state 2025-06-11 12:33:42 +10:00
psychedelicious
dd136a63a2 refactor(ui): org state in prep for new flow 2025-06-11 12:33:42 +10:00
psychedelicious
325f0a4c5b refactor(ui): image viewer & comparison convolutedness 2025-06-11 12:33:42 +10:00
psychedelicious
7b5ab0d458 feat(ui): default canvas tool is move 2025-06-11 12:33:42 +10:00
psychedelicious
4fa69176cb chore(ui): bump @reduxjs/toolkit to latest 2025-06-11 12:33:42 +10:00
psychedelicious
1418b0546c feat(ui): viewer is a modal (wip) 2025-06-11 12:33:42 +10:00
198 changed files with 2023 additions and 6195 deletions

1
.gitignore vendored
View File

@@ -180,7 +180,6 @@ cython_debug/
# Scratch folder
.scratch/
.vscode/
.zed/
# source installer files
installer/*zip

View File

@@ -1,12 +1,21 @@
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",
@@ -14,26 +23,17 @@ board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
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:
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),
result = ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
)
return result
except Exception:
raise HTTPException(status_code=500, detail="Failed to add image to board")
@@ -45,25 +45,14 @@ 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:
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),
)
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
return result
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove image from board")
@@ -83,25 +72,16 @@ async def add_images_to_board(
) -> AddImagesToBoardResult:
"""Adds a list of images to a board"""
try:
added_images: set[str] = set()
affected_boards: set[str] = set()
added_image_names: list[str] = []
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_images.add(image_name)
affected_boards.add(board_id)
affected_boards.add(old_board_id)
added_image_names.append(image_name)
except Exception:
pass
return AddImagesToBoardResult(
added_images=list(added_images),
affected_boards=list(affected_boards),
)
return AddImagesToBoardResult(board_id=board_id, added_image_names=added_image_names)
except Exception:
raise HTTPException(status_code=500, detail="Failed to add images to board")
@@ -120,20 +100,13 @@ async def remove_images_from_board(
) -> RemoveImagesFromBoardResult:
"""Removes a list of images from their board, if they had one"""
try:
removed_images: set[str] = set()
affected_boards: set[str] = set()
removed_image_names: list[str] = []
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_images.add(image_name)
affected_boards.add("none")
affected_boards.add(old_board_id)
removed_image_names.append(image_name)
except Exception:
pass
return RemoveImagesFromBoardResult(
removed_images=list(removed_images),
affected_boards=list(affected_boards),
)
return RemoveImagesFromBoardResult(removed_image_names=removed_image_names)
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, Literal, Optional
from typing import ClassVar, Optional
from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
@@ -14,17 +14,10 @@ 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 (
DeleteImagesResult,
ImageDTO,
ImageUrlsDTO,
StarredImagesResult,
UnstarredImagesResult,
)
from invokeai.app.services.images.images_common import ImageDTO, ImageUrlsDTO
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
@@ -160,30 +153,18 @@ async def create_image_upload_entry(
raise HTTPException(status_code=501, detail="Not implemented")
@images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult)
@images_router.delete("/i/{image_name}", operation_id="delete_image")
async def delete_image(
image_name: str = Path(description="The name of the image to delete"),
) -> DeleteImagesResult:
) -> None:
"""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:
@@ -395,32 +376,31 @@ async def list_image_dtos(
return image_dtos
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult)
class DeleteImagesFromListResult(BaseModel):
deleted_images: list[str]
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesFromListResult)
async def delete_images_from_list(
image_names: list[str] = Body(description="The list of names of images to delete", embed=True),
) -> DeleteImagesResult:
) -> DeleteImagesFromListResult:
try:
deleted_images: set[str] = set()
affected_boards: set[str] = set()
deleted_images: list[str] = []
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.add(image_name)
affected_boards.add(board_id)
deleted_images.append(image_name)
except Exception:
pass
return DeleteImagesResult(
deleted_images=list(deleted_images),
affected_boards=list(affected_boards),
)
return DeleteImagesFromListResult(deleted_images=deleted_images)
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete images")
@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult)
async def delete_uncategorized_images() -> DeleteImagesResult:
@images_router.delete(
"/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesFromListResult
)
async def delete_uncategorized_images() -> DeleteImagesFromListResult:
"""Deletes all images that are uncategorized"""
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
@@ -428,19 +408,14 @@ async def delete_uncategorized_images() -> DeleteImagesResult:
)
try:
deleted_images: set[str] = set()
affected_boards: set[str] = set()
deleted_images: list[str] = []
for image_name in image_names:
try:
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.add(image_name)
affected_boards.add("none")
deleted_images.append(image_name)
except Exception:
pass
return DeleteImagesResult(
deleted_images=list(deleted_images),
affected_boards=list(affected_boards),
)
return DeleteImagesFromListResult(deleted_images=deleted_images)
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete images")
@@ -449,50 +424,36 @@ 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=StarredImagesResult)
@images_router.post("/star", operation_id="star_images_in_list", response_model=ImagesUpdatedFromListResult)
async def star_images_in_list(
image_names: list[str] = Body(description="The list of names of images to star", embed=True),
) -> StarredImagesResult:
) -> ImagesUpdatedFromListResult:
try:
starred_images: set[str] = set()
affected_boards: set[str] = set()
updated_image_names: list[str] = []
for image_name in image_names:
try:
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")
ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=True))
updated_image_names.append(image_name)
except Exception:
pass
return StarredImagesResult(
starred_images=list(starred_images),
affected_boards=list(affected_boards),
)
return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
except Exception:
raise HTTPException(status_code=500, detail="Failed to star images")
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult)
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=ImagesUpdatedFromListResult)
async def unstar_images_in_list(
image_names: list[str] = Body(description="The list of names of images to unstar", embed=True),
) -> UnstarredImagesResult:
) -> ImagesUpdatedFromListResult:
try:
unstarred_images: set[str] = set()
affected_boards: set[str] = set()
updated_image_names: list[str] = []
for image_name in image_names:
try:
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")
ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=False))
updated_image_names.append(image_name)
except Exception:
pass
return UnstarredImagesResult(
unstarred_images=list(unstarred_images),
affected_boards=list(affected_boards),
)
return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
except Exception:
raise HTTPException(status_code=500, detail="Failed to unstar images")
@@ -563,92 +524,3 @@ 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

@@ -158,7 +158,7 @@ web_root_path = Path(list(web_dir.__path__)[0])
try:
app.mount("/", NoCacheStaticFiles(directory=Path(web_root_path, "dist"), html=True), name="ui")
except RuntimeError:
logger.warning(f"No UI found at {web_root_path}/dist, skipping UI mount")
logger.warn(f"No UI found at {web_root_path}/dist, skipping UI mount")
app.mount(
"/static", NoCacheStaticFiles(directory=Path(web_root_path, "static/")), name="static"
) # docs favicon is in here

View File

@@ -499,7 +499,7 @@ def validate_fields(model_fields: dict[str, FieldInfo], model_type: str) -> None
ui_type = field.json_schema_extra.get("ui_type", None)
if isinstance(ui_type, str) and ui_type.startswith("DEPRECATED_"):
logger.warning(f'"UIType.{ui_type.split("_")[-1]}" is deprecated, ignoring')
logger.warn(f'"UIType.{ui_type.split("_")[-1]}" is deprecated, ignoring')
field.json_schema_extra.pop("ui_type")
return None
@@ -582,8 +582,6 @@ def invocation(
fields: dict[str, tuple[Any, FieldInfo]] = {}
original_model_fields: dict[str, OriginalModelField] = {}
for field_name, field_info in cls.model_fields.items():
annotation = field_info.annotation
assert annotation is not None, f"{field_name} on invocation {invocation_type} has no type annotation."
@@ -591,7 +589,7 @@ def invocation(
f"{field_name} on invocation {invocation_type} has a non-dict json_schema_extra, did you forget to use InputField?"
)
original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info)
cls._original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info)
validate_field_default(cls.__name__, field_name, invocation_type, annotation, field_info)
@@ -615,7 +613,7 @@ def invocation(
raise InvalidVersionError(f'Invalid version string for node "{invocation_type}": "{version}"') from e
uiconfig["version"] = version
else:
logger.warning(f'No version specified for node "{invocation_type}", using "1.0.0"')
logger.warn(f'No version specified for node "{invocation_type}", using "1.0.0"')
uiconfig["version"] = "1.0.0"
cls.UIConfig = UIConfigBase(**uiconfig)
@@ -678,7 +676,6 @@ def invocation(
docstring = cls.__doc__
new_class = create_model(cls.__qualname__, __base__=cls, __module__=cls.__module__, **fields) # type: ignore
new_class.__doc__ = docstring
new_class._original_model_fields = original_model_fields
InvocationRegistry.register_invocation(new_class)

View File

@@ -114,13 +114,6 @@ class CompelInvocation(BaseInvocation):
c, _options = compel.build_conditioning_tensor_for_conjunction(conjunction)
del compel
del patched_tokenizer
del tokenizer
del ti_manager
del text_encoder
del text_encoder_info
c = c.detach().to("cpu")
conditioning_data = ConditioningFieldData(conditionings=[BasicConditioningInfo(embeds=c)])
@@ -229,10 +222,7 @@ class SDXLPromptInvocationBase:
else:
c_pooled = None
del compel
del patched_tokenizer
del tokenizer
del ti_manager
del text_encoder
del text_encoder_info

View File

@@ -437,7 +437,7 @@ class WithWorkflow:
workflow = None
def __init_subclass__(cls) -> None:
logger.warning(
logger.warn(
f"{cls.__module__.split('.')[0]}.{cls.__name__}: WithWorkflow is deprecated. Use `context.workflow` to access the workflow."
)
super().__init_subclass__()
@@ -578,7 +578,7 @@ def InputField(
if default_factory is not _Unset and default_factory is not None:
default = default_factory()
logger.warning('"default_factory" is not supported, calling it now to set "default"')
logger.warn('"default_factory" is not supported, calling it now to set "default"')
# These are the args we may wish pass to the pydantic `Field()` function
field_args = {

View File

@@ -24,6 +24,7 @@ from invokeai.frontend.cli.arg_parser import InvokeAIArgs
INIT_FILE = Path("invokeai.yaml")
DB_FILE = Path("invokeai.db")
LEGACY_INIT_FILE = Path("invokeai.init")
DEVICE = Literal["auto", "cpu", "cuda", "cuda:1", "mps"]
PRECISION = Literal["auto", "float16", "bfloat16", "float32"]
ATTENTION_TYPE = Literal["auto", "normal", "xformers", "sliced", "torch-sdp"]
ATTENTION_SLICE_SIZE = Literal["auto", "balanced", "max", 1, 2, 3, 4, 5, 6, 7, 8]
@@ -92,7 +93,7 @@ class InvokeAIAppConfig(BaseSettings):
vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.
lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.
pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to "backend:cudaMallocAsync" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.
device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.<br>Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)
device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.<br>Valid values: `auto`, `cpu`, `cuda`, `cuda:1`, `mps`
precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.<br>Valid values: `auto`, `float16`, `bfloat16`, `float32`
sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.
attention_type: Attention type.<br>Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`
@@ -175,7 +176,7 @@ class InvokeAIAppConfig(BaseSettings):
pytorch_cuda_alloc_conf: Optional[str] = Field(default=None, description="Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.")
# DEVICE
device: str = Field(default="auto", description="Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.<br>Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)", pattern=r"^(auto|cpu|mps|cuda(:\d+)?)$")
device: DEVICE = Field(default="auto", description="Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.")
precision: PRECISION = Field(default="auto", description="Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.")
# GENERATION

View File

@@ -1,11 +1,10 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Literal, Optional
from typing import Optional
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageCollectionCounts,
ImageRecord,
ImageRecordChanges,
ResourceOrigin,
@@ -98,44 +97,3 @@ 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 BaseModel, Field, StrictBool, StrictStr
from pydantic import Field, StrictBool, StrictStr
from invokeai.app.util.metaenum import MetaEnum
from invokeai.app.util.misc import get_iso_timestamp
@@ -207,8 +207,3 @@ 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,13 +1,12 @@
import sqlite3
from datetime import datetime
from typing import Literal, Optional, Union, cast
from typing import 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,
@@ -387,253 +386,3 @@ 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,12 +1,11 @@
from abc import ABC, abstractmethod
from typing import Callable, Literal, Optional
from typing import Callable, 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,
@@ -126,7 +125,7 @@ class ImageServiceABC(ABC):
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a paginated list of image DTOs with starred images first when starred_first=True."""
"""Gets a paginated list of image DTOs."""
pass
@abstractmethod
@@ -148,44 +147,3 @@ 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 BaseModel, Field
from pydantic import Field
from invokeai.app.services.image_records.image_records_common import ImageRecord
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
@@ -39,27 +39,3 @@ 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 Literal, Optional
from typing import Optional
from PIL.Image import Image as PILImageType
@@ -10,7 +10,6 @@ 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,
@@ -79,7 +78,7 @@ class ImageService(ImageServiceABC):
board_id=board_id, image_name=image_name
)
except Exception as e:
self.__invoker.services.logger.warning(f"Failed to add image to board {board_id}: {str(e)}")
self.__invoker.services.logger.warn(f"Failed to add image to board {board_id}: {str(e)}")
self.__invoker.services.image_files.save(
image_name=image_name, image=image, metadata=metadata, workflow=workflow, graph=graph
)
@@ -310,90 +309,3 @@ 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

@@ -148,7 +148,7 @@ class ModelInstallService(ModelInstallServiceBase):
def _clear_pending_jobs(self) -> None:
for job in self.list_jobs():
if not job.in_terminal_state:
self._logger.warning(f"Cancelling job {job.id}")
self._logger.warning("Cancelling job {job.id}")
self.cancel_job(job)
while True:
try:

View File

@@ -1,4 +1,3 @@
import gc
import traceback
from contextlib import suppress
from threading import BoundedSemaphore, Thread
@@ -440,12 +439,6 @@ class DefaultSessionProcessor(SessionProcessorBase):
poll_now_event.wait(self._polling_interval)
continue
# GC-ing here can reduce peak memory usage of the invoke process by freeing allocated memory blocks.
# Most queue items take seconds to execute, so the relative cost of a GC is very small.
# Python will never cede allocated memory back to the OS, so anything we can do to reduce the peak
# allocation is well worth it.
gc.collect()
self._invoker.services.logger.info(
f"Executing queue item {self._queue_item.item_id}, session {self._queue_item.session_id}"
)

View File

@@ -101,7 +101,11 @@ class SqliteSessionQueue(SessionQueueBase):
return cast(Union[int, None], cursor.fetchone()[0]) or 0
async def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
return await asyncio.to_thread(self._enqueue_batch, queue_id, batch, prepend)
def _enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
try:
cursor = self._conn.cursor()
# TODO: how does this work in a multi-user scenario?
current_queue_size = self._get_current_queue_size(queue_id)
max_queue_size = self.__invoker.services.configuration.max_queue_size
@@ -111,12 +115,8 @@ class SqliteSessionQueue(SessionQueueBase):
if prepend:
priority = self._get_highest_priority(queue_id) + 1
requested_count = await asyncio.to_thread(
calc_session_count,
batch=batch,
)
values_to_insert = await asyncio.to_thread(
prepare_values_to_insert,
requested_count = calc_session_count(batch)
values_to_insert = prepare_values_to_insert(
queue_id=queue_id,
batch=batch,
priority=priority,
@@ -124,16 +124,19 @@ class SqliteSessionQueue(SessionQueueBase):
)
enqueued_count = len(values_to_insert)
with self._conn:
cursor = self._conn.cursor()
cursor.executemany(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values_to_insert,
)
if requested_count > enqueued_count:
values_to_insert = values_to_insert[:max_new_queue_items]
cursor.executemany(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values_to_insert,
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
enqueue_result = EnqueueBatchResult(
queue_id=queue_id,

View File

@@ -296,7 +296,7 @@ class LoRAConfigBase(ABC, BaseModel):
from invokeai.backend.patches.lora_conversions.formats import flux_format_from_state_dict
sd = mod.load_state_dict(mod.path)
value = flux_format_from_state_dict(sd, mod.metadata())
value = flux_format_from_state_dict(sd)
mod.cache[key] = value
return value

View File

@@ -20,10 +20,6 @@ from invokeai.backend.model_manager.taxonomy import (
ModelType,
SubModelType,
)
from invokeai.backend.patches.lora_conversions.flux_aitoolkit_lora_conversion_utils import (
is_state_dict_likely_in_flux_aitoolkit_format,
lora_model_from_flux_aitoolkit_state_dict,
)
from invokeai.backend.patches.lora_conversions.flux_control_lora_utils import (
is_state_dict_likely_flux_control,
lora_model_from_flux_control_state_dict,
@@ -96,8 +92,6 @@ class LoRALoader(ModelLoader):
model = lora_model_from_flux_onetrainer_state_dict(state_dict=state_dict)
elif is_state_dict_likely_flux_control(state_dict=state_dict):
model = lora_model_from_flux_control_state_dict(state_dict=state_dict)
elif is_state_dict_likely_in_flux_aitoolkit_format(state_dict=state_dict):
model = lora_model_from_flux_aitoolkit_state_dict(state_dict=state_dict)
else:
raise ValueError(f"LoRA model is in unsupported FLUX format: {config.format}")
else:

View File

@@ -137,7 +137,6 @@ class FluxLoRAFormat(str, Enum):
Kohya = "flux.kohya"
OneTrainer = "flux.onetrainer"
Control = "flux.control"
AIToolkit = "flux.aitoolkit"
AnyVariant: TypeAlias = Union[ModelVariantType, ClipVariantType, None]

View File

@@ -46,10 +46,6 @@ class ModelPatcher:
text_encoder: Union[CLIPTextModel, CLIPTextModelWithProjection],
ti_list: List[Tuple[str, TextualInversionModelRaw]],
) -> Iterator[Tuple[CLIPTokenizer, TextualInversionManager]]:
if len(ti_list) == 0:
yield tokenizer, TextualInversionManager(tokenizer)
return
init_tokens_count = None
new_tokens_added = None

View File

@@ -1,63 +0,0 @@
import json
from dataclasses import dataclass, field
from typing import Any
import torch
from invokeai.backend.patches.layers.base_layer_patch import BaseLayerPatch
from invokeai.backend.patches.layers.utils import any_lora_layer_from_state_dict
from invokeai.backend.patches.lora_conversions.flux_diffusers_lora_conversion_utils import _group_by_layer
from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_TRANSFORMER_PREFIX
from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
from invokeai.backend.util import InvokeAILogger
def is_state_dict_likely_in_flux_aitoolkit_format(state_dict: dict[str, Any], metadata: dict[str, Any] = None) -> bool:
if metadata:
try:
software = json.loads(metadata.get("software", "{}"))
except json.JSONDecodeError:
return False
return software.get("name") == "ai-toolkit"
# metadata got lost somewhere
return any("diffusion_model" == k.split(".", 1)[0] for k in state_dict.keys())
@dataclass
class GroupedStateDict:
transformer: dict[str, Any] = field(default_factory=dict)
# might also grow CLIP and T5 submodels
def _group_state_by_submodel(state_dict: dict[str, Any]) -> GroupedStateDict:
logger = InvokeAILogger.get_logger()
grouped = GroupedStateDict()
for key, value in state_dict.items():
submodel_name, param_name = key.split(".", 1)
match submodel_name:
case "diffusion_model":
grouped.transformer[param_name] = value
case _:
logger.warning(f"Unexpected submodel name: {submodel_name}")
return grouped
def _rename_peft_lora_keys(state_dict: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
"""Renames keys from the PEFT LoRA format to the InvokeAI format."""
renamed_state_dict = {}
for key, value in state_dict.items():
renamed_key = key.replace(".lora_A.", ".lora_down.").replace(".lora_B.", ".lora_up.")
renamed_state_dict[renamed_key] = value
return renamed_state_dict
def lora_model_from_flux_aitoolkit_state_dict(state_dict: dict[str, torch.Tensor]) -> ModelPatchRaw:
state_dict = _rename_peft_lora_keys(state_dict)
by_layer = _group_by_layer(state_dict)
by_model = _group_state_by_submodel(by_layer)
layers: dict[str, BaseLayerPatch] = {}
for layer_key, layer_state_dict in by_model.transformer.items():
layers[FLUX_LORA_TRANSFORMER_PREFIX + layer_key] = any_lora_layer_from_state_dict(layer_state_dict)
return ModelPatchRaw(layers=layers)

View File

@@ -1,7 +1,4 @@
from invokeai.backend.model_manager.taxonomy import FluxLoRAFormat
from invokeai.backend.patches.lora_conversions.flux_aitoolkit_lora_conversion_utils import (
is_state_dict_likely_in_flux_aitoolkit_format,
)
from invokeai.backend.patches.lora_conversions.flux_control_lora_utils import is_state_dict_likely_flux_control
from invokeai.backend.patches.lora_conversions.flux_diffusers_lora_conversion_utils import (
is_state_dict_likely_in_flux_diffusers_format,
@@ -14,7 +11,7 @@ from invokeai.backend.patches.lora_conversions.flux_onetrainer_lora_conversion_u
)
def flux_format_from_state_dict(state_dict: dict, metadata: dict | None = None) -> FluxLoRAFormat | None:
def flux_format_from_state_dict(state_dict):
if is_state_dict_likely_in_flux_kohya_format(state_dict):
return FluxLoRAFormat.Kohya
elif is_state_dict_likely_in_flux_onetrainer_format(state_dict):
@@ -23,7 +20,5 @@ def flux_format_from_state_dict(state_dict: dict, metadata: dict | None = None)
return FluxLoRAFormat.Diffusers
elif is_state_dict_likely_flux_control(state_dict):
return FluxLoRAFormat.Control
elif is_state_dict_likely_in_flux_aitoolkit_format(state_dict, metadata):
return FluxLoRAFormat.AIToolkit
else:
return None

View File

@@ -12,13 +12,11 @@ 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': 'warn',
'no-console': 'error',
// 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

@@ -67,9 +67,8 @@
"chakra-react-select": "^4.9.2",
"cmdk": "^1.1.1",
"compare-versions": "^6.1.1",
"dockview": "^4.3.1",
"filesize": "^10.1.6",
"fracturedjsonjs": "^4.1.0",
"fracturedjsonjs": "^4.0.2",
"framer-motion": "^11.10.0",
"i18next": "^25.0.1",
"i18next-http-backend": "^3.0.2",

View File

@@ -50,15 +50,12 @@ dependencies:
compare-versions:
specifier: ^6.1.1
version: 6.1.1
dockview:
specifier: ^4.3.1
version: 4.3.1(react@18.3.1)
filesize:
specifier: ^10.1.6
version: 10.1.6
fracturedjsonjs:
specifier: ^4.1.0
version: 4.1.0
specifier: ^4.0.2
version: 4.0.2
framer-motion:
specifier: ^11.10.0
version: 11.10.0(react-dom@18.3.1)(react@18.3.1)
@@ -4495,19 +4492,6 @@ packages:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
dev: false
/dockview-core@4.3.1:
resolution: {integrity: sha512-cjGIXKc1wtHHkeKisuDLNt3HSHCVzvabxm1K9Auna27A9T3QR7ISOiTJyEUKUPllkcztFYBut0vwnnvwLnPAuQ==}
dev: false
/dockview@4.3.1(react@18.3.1):
resolution: {integrity: sha512-D4SvZPs1GJxGUBPkrehlKNGsWlSDaBiPuSYI+IEXnZ7b2bCUs1/h954sVs7xyykqEW3r6TkPKLWdTR/47Q7/QQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
dockview-core: 4.3.1
react: 18.3.1
dev: false
/doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -5296,8 +5280,8 @@ packages:
signal-exit: 4.1.0
dev: true
/fracturedjsonjs@4.1.0:
resolution: {integrity: sha512-qy6LPA8OOiiyRHt5/sNKDayD7h5r3uHmHxSOLbBsgtU/hkt5vOVWOR51MdfDbeCNfj7k/dKCRbXYm8FBAJcgWQ==}
/fracturedjsonjs@4.0.2:
resolution: {integrity: sha512-+vGJH9wK0EEhbbn50V2sOebLRaar1VL3EXr02kxchIwpkhQk0ItrPjIOtYPYuU9hNFpVzxjrPgzjtMJih+ae4A==}
dev: false
/framer-motion@10.18.0(react-dom@18.3.1)(react@18.3.1):

View File

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

View File

@@ -11,9 +11,11 @@ import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useCloseChakraTooltipsOnDragFix } from 'common/hooks/useCloseChakraTooltipsOnDragFix';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher';
import { toggleImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useReadinessWatcher } from 'features/queue/store/readiness';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { configChanged } from 'features/system/store/configSlice';
import { selectLanguage } from 'features/system/store/systemSelectors';
import i18n from 'i18n';
@@ -70,6 +72,12 @@ export const GlobalHookIsolator = memo(
useWorkflowBuilderWatcher();
useDynamicPromptsWatcher();
useRegisteredHotkeys({
id: 'toggleViewer',
category: 'viewer',
callback: toggleImageViewer,
});
return null;
}
);

View File

@@ -7,13 +7,11 @@ 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 imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
const imageDTO = useAppSelector(selectLastSelectedImage);
if (!imageDTO) {
return null;

View File

@@ -11,6 +11,7 @@ import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { ImageViewerModal } from 'features/gallery/components/ImageViewer/ImageViewer';
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal';
import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal';
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
@@ -60,6 +61,7 @@ export const GlobalModalIsolator = memo(() => {
<CanvasPasteModal />
</CanvasManagerProviderGate>
<LoadWorkflowFromGraphModal />
<ImageViewerModal />
</>
);
});

View File

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

@@ -7,6 +7,7 @@ import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
import { paramsReset } from 'features/controlLayers/store/paramsSlice';
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { sentImageToCanvas } from 'features/gallery/store/actions';
import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
@@ -93,6 +94,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
store.dispatch(canvasReset());
store.dispatch(rasterLayerAdded({ overrides, isSelected: true }));
store.dispatch(sentImageToCanvas());
$imageViewer.set(false);
toast({
title: t('toast.sentToCanvas'),
status: 'info',
@@ -162,10 +164,12 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
// Go to the canvas tab, open the image viewer, and enable send-to-gallery mode
store.dispatch(paramsReset());
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
$imageViewer.set(true);
break;
case 'canvas':
// Go to the canvas tab, close the image viewer, and disable send-to-gallery mode
store.dispatch(canvasReset());
$imageViewer.set(false);
break;
case 'workflows':
// Go to the workflows tab

View File

@@ -15,6 +15,8 @@ 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';
@@ -45,6 +47,10 @@ 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.board_id;
const deletedBoardId = action.meta.arg.originalArgs;
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?.image_name ?? null));
dispatch(imageSelected(selectedImage || null));
} else if (boardImagesData) {
dispatch(imageSelected(boardImagesData.items[0]?.image_name ?? null));
dispatch(imageSelected(boardImagesData.items[0] || 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]?.image_name ?? null));
dispatch(imageSelected(action.payload.items[0] ?? null));
}
},
});

View File

@@ -1,32 +1,12 @@
import { createAction } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { RootState } from 'app/store/store';
import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors';
import { selectListImagesQueryArgs } 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 { 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 || [];
};
import type { ImageDTO } from 'services/api/types';
export const galleryImageClicked = createAction<{
imageName: string;
imageDTO: ImageDTO;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
@@ -48,51 +28,45 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
startAppListening({
actionCreator: galleryImageClicked,
effect: (action, { dispatch, getState }) => {
const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
const state = getState();
const queryArgs = selectImageCollectionQueryArgs(state);
const queryArgs = selectListImagesQueryArgs(state);
const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state);
// 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]));
}
if (!queryResult.data) {
// Should never happen if we have clicked a gallery image
return;
}
const imageDTOs = queryResult.data.items;
const selection = state.gallery.selection;
if (altKey) {
if (state.gallery.imageToCompare === imageName) {
if (state.gallery.imageToCompare?.image_name === imageDTO.image_name) {
dispatch(imageToCompareChanged(null));
} else {
dispatch(imageToCompareChanged(imageName));
dispatch(imageToCompareChanged(imageDTO));
}
} else if (shiftKey) {
const rangeEndImageName = imageName;
const lastSelectedImage = selection.at(-1);
const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage);
const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName);
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);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = imageNames.slice(start, end + 1);
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect))));
const imagesToSelect = imageDTOs.slice(start, end + 1);
dispatch(selectionChanged(selection.concat(imagesToSelect)));
}
} else if (ctrlKey || metaKey) {
if (selection.some((n) => n === imageName) && selection.length > 1) {
dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName))));
if (selection.some((i) => i.image_name === imageDTO.image_name) && selection.length > 1) {
dispatch(selectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name)));
} else {
dispatch(selectionChanged(uniq(selection.concat(imageName))));
dispatch(selectionChanged(selection.concat(imageDTO)));
}
} else {
dispatch(selectionChanged([imageName]));
dispatch(selectionChanged([imageDTO]));
}
},
});

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 === lastImage?.image_name)) {
dispatch(selectionChanged(lastImage ? [lastImage.image_name] : []));
if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) {
dispatch(selectionChanged(lastImage ? [lastImage] : []));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) {
dispatch(selectionChanged(firstImage ? [firstImage.image_name] : []));
if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) {
dispatch(selectionChanged(firstImage ? [firstImage] : []));
}
}
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 !== lastImage.image_name) {
dispatch(imageToCompareChanged(lastImage.image_name));
if (lastImage && imageToCompare?.image_name !== lastImage.image_name) {
dispatch(imageToCompareChanged(lastImage));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (firstImage && imageToCompare !== firstImage.image_name) {
dispatch(imageToCompareChanged(firstImage.image_name));
if (firstImage && imageToCompare?.image_name !== firstImage.image_name) {
dispatch(imageToCompareChanged(firstImage));
}
}
return;

View File

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

View File

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

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

View File

@@ -17,7 +17,6 @@ const Loading = () => {
right={0}
bottom={0}
left={0}
zIndex={99999}
>
<Image src={InvokeLogoWhite} w="8rem" h="8rem" />
<Spinner

View File

@@ -1,6 +1,6 @@
.os-scrollbar {
/* The size of the scrollbar */
--os-size: 8px;
--os-size: 9px;
/* 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.5);
/* --os-track-bg: rgba(0, 0, 0, 0.3); */
/* The :hover background of the scrollbar track */
--os-track-bg-hover: rgba(0, 0, 0, 0.5);
/* --os-track-bg-hover: rgba(0, 0, 0, 0.3); */
/* The :active background of the scrollbar track */
--os-track-bg-active: rgba(0, 0, 0, 0.6);
/* --os-track-bg-active: rgba(0, 0, 0, 0.3); */
/* 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(--invoke-colors-base-400);
/* --os-handle-bg: var(--invokeai-colors-accentAlpha-500); */
/* The :hover background of the scrollbar handle */
--os-handle-bg-hover: var(--invoke-colors-base-300);
/* --os-handle-bg-hover: var(--invokeai-colors-accentAlpha-700); */
/* The :active background of the scrollbar handle */
--os-handle-bg-active: var(--invoke-colors-base-250);
/* --os-handle-bg-active: var(--invokeai-colors-accentAlpha-800); */
/* 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: 32px;
--os-handle-min-size: 50px;
/* 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: -1px;
/* --os-handle-interactive-area-offset: 0; */
}
.os-scrollbar-handle {
/* cursor: grab; */
cursor: grab;
}
.os-scrollbar-handle:active {
/* cursor: grabbing; */
cursor: grabbing;
}

View File

@@ -1,29 +0,0 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
import { useTimeoutCallback } from 'common/hooks/useTimeoutCallback';
import type { RefObject } from 'react';
import { useEffect } from 'react';
export const useCallbackOnDragEnter = (cb: () => void, ref: RefObject<HTMLElement>, delay = 300) => {
const [run, cancel] = useTimeoutCallback(cb, delay);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return combine(
dropTargetForElements({
element,
onDragEnter: run,
onDragLeave: cancel,
}),
dropTargetForExternal({
element,
onDragEnter: run,
onDragLeave: cancel,
})
);
}, [cancel, ref, run]);
};

View File

@@ -61,15 +61,6 @@ export const useGlobalHotkeys = () => {
dependencies: [clearQueue],
});
useRegisteredHotkeys({
id: 'selectGenerateTab',
category: 'app',
callback: () => {
dispatch(setActiveTab('generate'));
},
dependencies: [dispatch],
});
useRegisteredHotkeys({
id: 'selectCanvasTab',
category: 'app',

View File

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

View File

@@ -1,21 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
export const useTimeoutCallback = (callback: () => void, delay: number, onCancel?: () => void) => {
const timeoutRef = useRef<number | null>(null);
const cancel = useCallback(() => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
onCancel?.();
}
}, [onCancel]);
const callWithTimeout = useCallback(() => {
cancel();
timeoutRef.current = window.setTimeout(() => {
callback();
timeoutRef.current = null;
}, delay);
}, [callback, cancel, delay]);
const api = useMemo(() => [callWithTimeout, cancel] as const, [callWithTimeout, cancel]);
return api;
};

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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';
@@ -11,11 +12,11 @@ export const changeBoardModalSlice = createSlice({
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isModalOpen = action.payload;
},
imagesToChangeSelected: (state, action: PayloadAction<string[]>) => {
state.image_names = action.payload;
imagesToChangeSelected: (state, action: PayloadAction<ImageDTO[]>) => {
state.imagesToChange = action.payload;
},
changeBoardReset: (state) => {
state.image_names = [];
state.imagesToChange = [];
state.isModalOpen = false;
},
},

View File

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

View File

@@ -1,18 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import {
ContextMenu,
Divider,
Flex,
IconButton,
Menu,
MenuButton,
MenuList,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from '@invoke-ai/ui-library';
import { ContextMenu, Divider, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress';
@@ -26,15 +13,12 @@ import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context';
import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel';
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
import { Transform } from 'features/controlLayers/components/Transform/Transform';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
import { memo, useCallback } from 'react';
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
@@ -76,107 +60,87 @@ export const AdvancedSession = memo(({ id }: { id: string | null }) => {
}, []);
return (
<Tabs w="full" h="full">
<TabList>
<Tab>Welcome</Tab>
<Tab>Workspace</Tab>
<Tab>Viewer</Tab>
</TabList>
<TabPanels w="full" h="full">
<TabPanel w="full" h="full" justifyContent="center">
<GenerateLaunchpadPanel />
</TabPanel>
<TabPanel w="full" h="full">
<FocusRegionWrapper region="canvas" sx={FOCUS_REGION_STYLES}>
<Flex
tabIndex={-1}
borderRadius="base"
position="relative"
flexDirection="column"
height="full"
width="full"
gap={2}
alignItems="center"
justifyContent="center"
overflow="hidden"
>
<FocusRegionWrapper region="canvas" sx={FOCUS_REGION_STYLES}>
<Flex
tabIndex={-1}
borderRadius="base"
position="relative"
flexDirection="column"
height="full"
width="full"
gap={2}
alignItems="center"
justifyContent="center"
overflow="hidden"
>
<CanvasManagerProviderGate>
<CanvasToolbar />
</CanvasManagerProviderGate>
<Divider />
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} withLongPress={false}>
{(ref) => (
<Flex ref={ref} sx={canvasBgSx} data-dynamic-grid={dynamicGrid}>
<InvokeCanvasComponent />
<CanvasManagerProviderGate>
<CanvasToolbar />
</CanvasManagerProviderGate>
<Divider />
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} withLongPress={false}>
{(ref) => (
<Flex ref={ref} sx={canvasBgSx} data-dynamic-grid={dynamicGrid}>
<InvokeCanvasComponent />
<CanvasManagerProviderGate>
<Flex
position="absolute"
flexDir="column"
top={1}
insetInlineStart={1}
pointerEvents="none"
gap={2}
alignItems="flex-start"
>
{showHUD && <CanvasHUD />}
<CanvasAlertsSelectedEntityStatus />
<CanvasAlertsPreserveMask />
<CanvasAlertsInvocationProgress />
</Flex>
<Flex position="absolute" top={1} insetInlineEnd={1}>
<Menu>
<MenuButton as={IconButton} icon={<PiDotsThreeOutlineVerticalFill />} colorScheme="base" />
<MenuContent />
</Menu>
</Flex>
</CanvasManagerProviderGate>
</Flex>
)}
</ContextMenu>
{id !== null && (
<CanvasManagerProviderGate>
<CanvasSessionContextProvider type="advanced" id={id}>
<Flex
position="absolute"
flexDir="column"
bottom={4}
gap={2}
align="center"
justify="center"
left={4}
right={4}
>
<Flex position="relative" maxW="full" w="full" h={108}>
<StagingAreaItemsList />
</Flex>
<Flex gap={2}>
<StagingAreaToolbar />
</Flex>
</Flex>
</CanvasSessionContextProvider>
</CanvasManagerProviderGate>
)}
<Flex position="absolute" bottom={4}>
<CanvasManagerProviderGate>
<Filter />
<Transform />
<SelectObject />
</CanvasManagerProviderGate>
</Flex>
<CanvasManagerProviderGate>
<CanvasDropArea />
<Flex
position="absolute"
flexDir="column"
top={1}
insetInlineStart={1}
pointerEvents="none"
gap={2}
alignItems="flex-start"
>
{showHUD && <CanvasHUD />}
<CanvasAlertsSelectedEntityStatus />
<CanvasAlertsPreserveMask />
<CanvasAlertsInvocationProgress />
</Flex>
<Flex position="absolute" top={1} insetInlineEnd={1}>
<Menu>
<MenuButton as={IconButton} icon={<PiDotsThreeOutlineVerticalFill />} colorScheme="base" />
<MenuContent />
</Menu>
</Flex>
</CanvasManagerProviderGate>
</Flex>
</FocusRegionWrapper>
</TabPanel>
<TabPanel w="full" h="full">
<Flex flexDir="column" w="full" h="full">
<ViewerToolbar />
<ImageViewer />
</Flex>
</TabPanel>
</TabPanels>
</Tabs>
)}
</ContextMenu>
{id !== null && (
<CanvasManagerProviderGate>
<CanvasSessionContextProvider type="advanced" id={id}>
<Flex
position="absolute"
flexDir="column"
bottom={4}
gap={2}
align="center"
justify="center"
left={4}
right={4}
>
<Flex position="relative" maxW="full" w="full" h={108}>
<StagingAreaItemsList />
</Flex>
<Flex gap={2}>
<StagingAreaToolbar />
</Flex>
</Flex>
</CanvasSessionContextProvider>
</CanvasManagerProviderGate>
)}
<Flex position="absolute" bottom={4}>
<CanvasManagerProviderGate>
<Filter />
<Transform />
<SelectObject />
</CanvasManagerProviderGate>
</Flex>
<CanvasManagerProviderGate>
<CanvasDropArea />
</CanvasManagerProviderGate>
</Flex>
</FocusRegionWrapper>
);
});
AdvancedSession.displayName = 'AdvancedSession';

View File

@@ -1,20 +1,25 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { Divider, Flex, type SystemStyleObject } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
import { CanvasAddEntityButtons } from 'features/controlLayers/components/CanvasAddEntityButtons';
import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityList';
import { EntityListSelectedEntityActionBar } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectHasEntities } from 'features/controlLayers/store/selectors';
import { memo } from 'react';
import { ParamDenoisingStrength } from './ParamDenoisingStrength';
export const CanvasLayersPanel = memo(() => {
const FOCUS_REGION_STYLES: SystemStyleObject = {
width: 'full',
height: 'full',
};
export const CanvasLayersPanelContent = memo(() => {
const hasEntities = useAppSelector(selectHasEntities);
return (
<CanvasManagerProviderGate>
<Flex flexDir="column" gap={2} w="full" h="full" p={2}>
<FocusRegionWrapper region="layers" sx={FOCUS_REGION_STYLES}>
<Flex flexDir="column" gap={2} w="full" h="full">
<EntityListSelectedEntityActionBar />
<Divider py={0} />
<ParamDenoisingStrength />
@@ -22,8 +27,8 @@ export const CanvasLayersPanel = memo(() => {
{!hasEntities && <CanvasAddEntityButtons />}
{hasEntities && <CanvasEntityList />}
</Flex>
</CanvasManagerProviderGate>
</FocusRegionWrapper>
);
});
CanvasLayersPanel.displayName = 'CanvasLayersPanel';
CanvasLayersPanelContent.displayName = 'CanvasLayersPanelContent';

View File

@@ -1,8 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import {
Divider,
Flex,
Icon,
IconButton,
Image,
Popover,
@@ -12,7 +10,6 @@ import {
PopoverContent,
Portal,
Skeleton,
Text,
} from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { POPPER_MODIFIERS } from 'common/components/InformationalPopover/constants';
@@ -23,10 +20,8 @@ import { RefImageHeader } from 'features/controlLayers/components/RefImage/RefIm
import { RefImageSettings } from 'features/controlLayers/components/RefImage/RefImageSettings';
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { isIPAdapterConfig } from 'features/controlLayers/store/types';
import { round } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiExclamationMarkBold, PiImageBold } from 'react-icons/pi';
import { memo, useCallback, useRef } from 'react';
import { PiImageBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
// There is some awkwardness here with closing the popover when clicking outside of it, related to Chakra's
@@ -84,75 +79,11 @@ export const RefImage = memo(() => {
});
RefImage.displayName = 'RefImage';
const baseSx: SystemStyleObject = {
opacity: 0.7,
transitionProperty: 'opacity',
transitionDuration: 'normal',
position: 'relative',
_hover: {
opacity: 1,
},
'&[data-is-open="true"]': {
opacity: 1,
},
'&[data-is-error="true"]': {
borderColor: 'error.500',
borderWidth: 2,
},
};
const weightDisplaySx: SystemStyleObject = {
pointerEvents: 'none',
transitionProperty: 'opacity',
transitionDuration: 'normal',
opacity: 0,
'&[data-visible="true"]': {
opacity: 1,
},
};
const getImageSxWithWeight = (weight: number): SystemStyleObject => {
const fillPercentage = Math.max(0, Math.min(100, weight * 100));
return {
...baseSx,
_after: {
content: '""',
position: 'absolute',
inset: 0,
background: `linear-gradient(to top, transparent ${fillPercentage}%, rgba(0, 0, 0, 0.8) ${fillPercentage}%)`,
pointerEvents: 'none',
borderRadius: 'base',
},
};
};
const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => {
const id = useRefImageIdContext();
const entity = useRefImageEntity(id);
const [showWeightDisplay, setShowWeightDisplay] = useState(false);
const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken);
const sx = useMemo(() => {
if (!isIPAdapterConfig(entity.config)) {
return baseSx;
}
return getImageSxWithWeight(entity.config.weight);
}, [entity.config]);
useEffect(() => {
if (!isIPAdapterConfig(entity.config)) {
return;
}
setShowWeightDisplay(true);
const timeout = window.setTimeout(() => {
setShowWeightDisplay(false);
}, 1000);
return () => {
window.clearTimeout(timeout);
};
}, [entity.config]);
if (!entity.config.image) {
return (
<PopoverAnchor>
@@ -176,62 +107,25 @@ const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => {
}
return (
<PopoverAnchor>
<Flex
position="relative"
<Image
borderWidth={1}
borderStyle="solid"
borderRadius="base"
aspectRatio="1/1"
maxW="full"
maxH="full"
flexShrink={0}
sx={sx}
data-is-open={disclosure.isOpen}
data-is-error={!entity.config.model}
id={getRefImagePopoverTriggerId(id)}
role="button"
src={imageDTO?.thumbnail_url}
objectFit="contain"
aspectRatio="1/1"
// width={imageDTO?.width}
height={imageDTO?.height}
fallback={<Skeleton h="full" aspectRatio="1/1" />}
maxW="full"
maxH="full"
borderRadius="base"
onClick={disclosure.toggle}
cursor="pointer"
>
<Image
src={imageDTO?.thumbnail_url}
objectFit="contain"
aspectRatio="1/1"
height={imageDTO?.height}
fallback={<Skeleton h="full" aspectRatio="1/1" />}
maxW="full"
maxH="full"
borderRadius="base"
/>
{isIPAdapterConfig(entity.config) && (
<Flex
position="absolute"
inset={0}
fontWeight="semibold"
alignItems="center"
justifyContent="center"
zIndex={1}
data-visible={showWeightDisplay}
sx={weightDisplaySx}
>
<Text filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))">
{`${round(entity.config.weight * 100, 2)}%`}
</Text>
</Flex>
)}
{!entity.config.model && (
<Icon
position="absolute"
top="50%"
left="50%"
transform="translateX(-50%) translateY(-50%)"
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
color="error.500"
boxSize={16}
as={PiExclamationMarkBold}
/>
)}
</Flex>
flexShrink={0}
// sx={imageSx}
// data-is-open={disclosure.isOpen}
/>
</PopoverAnchor>
);
});

View File

@@ -47,7 +47,7 @@ export const RefImageModel = memo(({ modelKey, onChangeModel }: Props) => {
return (
<Tooltip label={selectedModel?.description}>
<FormControl isInvalid={!value || currentBaseModel !== selectedModel?.base} w="full" minW={0}>
<FormControl isInvalid={!value || currentBaseModel !== selectedModel?.base} w="full">
<Combobox
options={options}
placeholder={t('common.placeholderSelectAModel')}

View File

@@ -1,36 +0,0 @@
import { Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { InitialStateMainModelPicker } from './InitialStateMainModelPicker';
import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference';
import { LaunchpadEditImageButton } from './LaunchpadEditImageButton';
import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton';
import { LaunchpadUseALayoutImageButton } from './LaunchpadUseALayoutImageButton';
export const CanvasLaunchpadPanel = memo(() => {
return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
<Heading mb={4}>Edit and refine on Canvas.</Heading>
<Flex flexDir="column" gap={8}>
<Grid gridTemplateColumns="1fr 1fr" gap={8}>
<InitialStateMainModelPicker />
<Flex flexDir="column" gap={2} justifyContent="center">
<Text>
Want to learn what prompts work best for each model?{' '}
<Button as="a" variant="link" href="#" size="sm">
Check our our Model Guide.
</Button>
</Text>
</Flex>
</Grid>
<LaunchpadGenerateFromTextButton />
<LaunchpadAddStyleReference />
<LaunchpadEditImageButton />
<LaunchpadUseALayoutImageButton />
</Flex>
</Flex>
</Flex>
);
});
CanvasLaunchpadPanel.displayName = 'CanvasLaunchpadPanel';

View File

@@ -1,26 +1,29 @@
import { Alert, Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library';
import { Alert, Button, Divider, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InitialStateAddAStyleReference } from 'features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference';
import { InitialStateGenerateFromText } from 'features/controlLayers/components/SimpleSession/InitialStateGenerateFromText';
import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker';
import { LaunchpadAddStyleReference } from 'features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton';
export const GenerateLaunchpadPanel = memo(() => {
export const InitialState = memo(() => {
const dispatch = useAppDispatch();
const newCanvasSession = useCallback(() => {
dispatch(setActiveTab('canvas'));
}, [dispatch]);
return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
<Heading mb={4}>Generate images from text prompts.</Heading>
<Flex flexDir="column" gap={8}>
<Grid gridTemplateColumns="1fr 1fr" gap={8}>
<Flex flexDir="column" h="full" w="full" gap={2}>
<Flex px={2} alignItems="center" minH="24px">
<Heading size="sm">Get Started</Heading>
</Flex>
<Divider />
<Flex flexDir="column" h="full" justifyContent="center" mx={16}>
<Heading mb={4}>Get started with Invoke.</Heading>
<Flex flexDir="column" gap={4}>
<Grid gridTemplateColumns="1fr 1fr" gap={4}>
<InitialStateMainModelPicker />
<Flex flexDir="column" gap={2} justifyContent="center">
<Flex flexDir="column" gap={2}>
<Text>
Want to learn what prompts work best for each model?{' '}
<Button as="a" variant="link" href="#" size="sm">
@@ -29,8 +32,8 @@ export const GenerateLaunchpadPanel = memo(() => {
</Text>
</Flex>
</Grid>
<LaunchpadGenerateFromTextButton />
<LaunchpadAddStyleReference />
<InitialStateGenerateFromText />
<InitialStateAddAStyleReference />
<Alert status="info" borderRadius="base" flexDir="column" gap={2} overflow="unset">
<Text fontSize="md" fontWeight="semibold">
Looking to get more control, edit, and iterate on your images?
@@ -44,4 +47,4 @@ export const GenerateLaunchpadPanel = memo(() => {
</Flex>
);
});
GenerateLaunchpadPanel.displayName = 'GenerateLaunchpad';
InitialState.displayName = 'InitialState';

View File

@@ -1,7 +1,7 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton';
import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
@@ -13,7 +13,7 @@ import type { ImageDTO } from 'services/api/types';
const dndTargetData = addGlobalReferenceImageDndTarget.getData();
export const LaunchpadAddStyleReference = memo(() => {
export const InitialStateAddAStyleReference = memo(() => {
const { dispatch, getState } = useAppStore();
const uploadOptions = useMemo(
@@ -32,7 +32,7 @@ export const LaunchpadAddStyleReference = memo(() => {
const uploadApi = useImageUploadButton(uploadOptions);
return (
<LaunchpadButton {...uploadApi.getUploadButtonProps()} position="relative" gap={8}>
<InitialStateButtonGridItem {...uploadApi.getUploadButtonProps()} position="relative" gap={8}>
<Icon as={PiUserCircleGearBold} boxSize={8} color="base.500" />
<Flex flexDir="column" alignItems="flex-start" gap={2}>
<Heading size="sm">Add a Style Reference</Heading>
@@ -43,7 +43,7 @@ export const LaunchpadAddStyleReference = memo(() => {
<input {...uploadApi.getUploadInputProps()} />
</Flex>
<DndDropTarget dndTarget={newCanvasFromImageDndTarget} dndTargetData={dndTargetData} label="Drop" />
</LaunchpadButton>
</InitialStateButtonGridItem>
);
});
LaunchpadAddStyleReference.displayName = 'LaunchpadAddStyleReference';
InitialStateAddAStyleReference.displayName = 'InitialStateAddAStyleReference';

View File

@@ -2,7 +2,7 @@ import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button, forwardRef } from '@invoke-ai/ui-library';
import { memo } from 'react';
export const LaunchpadButton = memo(
export const InitialStateButtonGridItem = memo(
forwardRef(({ children, ...rest }: ButtonProps, ref) => {
return (
<Button
@@ -26,4 +26,4 @@ export const LaunchpadButton = memo(
})
);
LaunchpadButton.displayName = 'LaunchpadButton';
InitialStateButtonGridItem.displayName = 'InitialStateButtonGridItem';

View File

@@ -1,7 +1,7 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton';
import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem';
import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { newCanvasFromImage } from 'features/imageActions/actions';
@@ -13,7 +13,7 @@ const NEW_CANVAS_OPTIONS = { type: 'raster_layer', withInpaintMask: true } as co
const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS);
export const LaunchpadEditImageButton = memo(() => {
export const InitialStateEditImageCard = memo(() => {
const { getState, dispatch } = useAppStore();
const onUpload = useCallback(
@@ -25,18 +25,16 @@ export const LaunchpadEditImageButton = memo(() => {
const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload });
return (
<LaunchpadButton {...uploadApi.getUploadButtonProps()} position="relative" gap={8}>
<InitialStateButtonGridItem {...uploadApi.getUploadButtonProps()}>
<Icon as={PiPencilBold} boxSize={8} color="base.500" />
<Flex flexDir="column" alignItems="flex-start" gap={2}>
<Heading size="sm">Edit Image</Heading>
<Text color="base.300">Add an image to refine.</Text>
</Flex>
<Flex position="absolute" right={3} bottom={3}>
<Heading size="sm">Edit Image</Heading>
<Text color="base.300">Add an image to refine.</Text>
<Flex w="full" justifyContent="flex-end" p={2}>
<PiUploadBold />
<input {...uploadApi.getUploadInputProps()} />
</Flex>
<DndDropTarget dndTarget={newCanvasFromImageDndTarget} dndTargetData={dndTargetData} label="Drop" />
</LaunchpadButton>
</InitialStateButtonGridItem>
);
});
LaunchpadEditImageButton.displayName = 'LaunchpadEditImageButton';
InitialStateEditImageCard.displayName = 'InitialStateEditImageCard';

View File

@@ -1,5 +1,5 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton';
import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem';
import { memo } from 'react';
import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi';
@@ -11,9 +11,9 @@ const focusOnPrompt = () => {
}
};
export const LaunchpadGenerateFromTextButton = memo(() => {
export const InitialStateGenerateFromText = memo(() => {
return (
<LaunchpadButton onClick={focusOnPrompt} position="relative" gap={8}>
<InitialStateButtonGridItem onClick={focusOnPrompt} position="relative" gap={8}>
<Icon as={PiTextAaBold} boxSize={8} color="base.500" />
<Flex flexDir="column" alignItems="flex-start" gap={2}>
<Heading size="sm">Generate from Text</Heading>
@@ -22,7 +22,7 @@ export const LaunchpadGenerateFromTextButton = memo(() => {
<Flex position="absolute" right={3} bottom={3}>
<PiCursorTextBold />
</Flex>
</LaunchpadButton>
</InitialStateButtonGridItem>
);
});
LaunchpadGenerateFromTextButton.displayName = 'LaunchpadGenerateFromTextButton';
InitialStateGenerateFromText.displayName = 'InitialStateGenerateFromText';

View File

@@ -30,16 +30,16 @@ export const InitialStateMainModelPicker = memo(() => {
return (
<FormControl orientation="vertical" alignItems="unset">
<FormLabel display="flex" fontSize="md" gap={2}>
Select your Model{' '}
{isFluxDevSelected && (
<InformationalPopover feature="fluxDevLicense" hideDisable={true}>
<Flex justifyContent="flex-start">
<Icon as={MdMoneyOff} />
</Flex>
</InformationalPopover>
)}
</FormLabel>
<InformationalPopover feature="paramModel">
<FormLabel>Select your Model</FormLabel>
</InformationalPopover>
{isFluxDevSelected && (
<InformationalPopover feature="fluxDevLicense" hideDisable={true}>
<Flex justifyContent="flex-start">
<Icon as={MdMoneyOff} />
</Flex>
</InformationalPopover>
)}
<ModelPicker modelConfigs={modelConfigs} selectedModelConfig={selectedModelConfig} onChange={onChange} grouped />
</FormControl>
);

View File

@@ -1,6 +1,7 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem';
import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { newCanvasFromImage } from 'features/imageActions/actions';
@@ -8,13 +9,11 @@ import { memo, useCallback } from 'react';
import { PiRectangleDashedBold, PiUploadBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
import { LaunchpadButton } from './LaunchpadButton';
const NEW_CANVAS_OPTIONS = { type: 'control_layer', withResize: true } as const;
const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS);
export const LaunchpadUseALayoutImageButton = memo(() => {
export const InitialStateUseALayoutImageCard = memo(() => {
const { getState, dispatch } = useAppStore();
const onUpload = useCallback(
@@ -26,18 +25,16 @@ export const LaunchpadUseALayoutImageButton = memo(() => {
const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload });
return (
<LaunchpadButton {...uploadApi.getUploadButtonProps()} position="relative" gap={8}>
<InitialStateButtonGridItem {...uploadApi.getUploadButtonProps()}>
<Icon as={PiRectangleDashedBold} boxSize={8} color="base.500" />
<Flex flexDir="column" alignItems="flex-start" gap={2}>
<Heading size="sm">Use a Layout Image</Heading>
<Text color="base.300">Add an image to control composition.</Text>
</Flex>
<Flex position="absolute" right={3} bottom={3}>
<Heading size="sm">Use a Layout Image</Heading>
<Text color="base.300">Add an image to control composition.</Text>
<Flex w="full" justifyContent="flex-end" p={2}>
<PiUploadBold />
<input {...uploadApi.getUploadInputProps()} />
</Flex>
<DndDropTarget dndTarget={newCanvasFromImageDndTarget} dndTargetData={dndTargetData} label="Drop" />
</LaunchpadButton>
</InitialStateButtonGridItem>
);
});
LaunchpadUseALayoutImageButton.displayName = 'LaunchpadUseALayoutImageButton';
InitialStateUseALayoutImageCard.displayName = 'InitialStateUseALayoutImageCard';

View File

@@ -11,7 +11,6 @@ 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';
@@ -47,28 +46,12 @@ 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}
onDoubleClick={onDoubleClick}
>
<Flex id={getQueueItemElementId(item.item_id)} sx={sx} data-selected={isSelected} onClick={onClick}>
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail />}
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}

View File

@@ -0,0 +1,16 @@
import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context';
import { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState';
import { StagingArea } from 'features/controlLayers/components/SimpleSession/StagingArea';
import { memo } from 'react';
export const SimpleSession = memo(({ id }: { id: string | null }) => {
if (id === null) {
return <InitialState />;
}
return (
<CanvasSessionContextProvider type="simple" id={id}>
<StagingArea />
</CanvasSessionContextProvider>
);
});
SimpleSession.displayName = 'SimpleSession';

View File

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

View File

@@ -1,13 +0,0 @@
import { Flex, Heading } from '@invoke-ai/ui-library';
import { memo } from 'react';
export const UpscalingLaunchpadPanel = memo(() => {
return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
<Heading mb={4}>Upscale and add detail.</Heading>
</Flex>
</Flex>
);
});
UpscalingLaunchpadPanel.displayName = 'UpscalingLaunchpadPanel';

View File

@@ -1,13 +0,0 @@
import { Flex, Heading } from '@invoke-ai/ui-library';
import { memo } from 'react';
export const WorkflowsLaunchpadPanel = memo(() => {
return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
<Heading mb={4}>Go deep with Workflows.</Heading>
</Flex>
</Flex>
);
});
WorkflowsLaunchpadPanel.displayName = 'WorkflowsLaunchpadPanel';

View File

@@ -2,7 +2,6 @@ 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';
@@ -14,11 +13,6 @@ 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;
@@ -97,7 +91,7 @@ type CanvasSessionContextValue = {
$selectedItem: Atom<S['SessionQueueItem'] | null>;
$selectedItemIndex: Atom<number | null>;
$selectedItemOutputImageDTO: Atom<ImageDTO | null>;
$autoSwitch: WritableAtom<AutoSwitchMode>;
$autoSwitch: WritableAtom<boolean>;
selectNext: () => void;
selectPrev: () => void;
selectFirst: () => void;
@@ -122,17 +116,8 @@ 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.
@@ -142,7 +127,7 @@ export const CanvasSessionContextProvider = memo(
/**
* Whether auto-switch is enabled.
*/
const $autoSwitch = useState(() => atom<AutoSwitchMode>('switch_on_start'))[0];
const $autoSwitch = useState(() => atom(true))[0];
/**
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
@@ -292,12 +277,12 @@ export const CanvasSessionContextProvider = memo(
imageLoaded: true,
});
}
if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') {
if ($lastCompletedItemId.get() === itemId) {
$selectedItemId.set(itemId);
$lastCompletedItemId.set(null);
}
},
[$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId]
[$lastCompletedItemId, $progressData, $selectedItemId]
);
// Set up socket listeners
@@ -320,9 +305,6 @@ 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);
@@ -332,7 +314,7 @@ export const CanvasSessionContextProvider = memo(
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
}, [$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId, session.id, socket]);
// Set up state subscriptions and effects
useEffect(() => {
@@ -351,39 +333,29 @@ export const CanvasSessionContextProvider = memo(
});
// Handle cases that could result in a nonexistent queue item being selected.
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 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 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) => {
@@ -466,7 +438,7 @@ export const CanvasSessionContextProvider = memo(
if (lastLoadedItemId === null) {
return;
}
if ($autoSwitch.get() === 'switch_on_finish') {
if ($autoSwitch.get()) {
$selectedItemId.set(lastLoadedItemId);
}
$lastLoadedItemId.set(null);
@@ -489,17 +461,7 @@ export const CanvasSessionContextProvider = memo(
$progressData.set({});
$selectedItemId.set(null);
};
}, [
$autoSwitch,
$items,
$lastLoadedItemId,
$lastStartedItemId,
$progressData,
$selectedItemId,
selectQueueItems,
session.id,
store,
]);
}, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]);
const value = useMemo<CanvasSessionContextValue>(
() => ({

View File

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

View File

@@ -0,0 +1,17 @@
import { Button } from '@invoke-ai/ui-library';
import { $simpleId } from 'features/ui/components/MainPanelContent';
import { memo, useCallback } from 'react';
export const StartOverButton = memo(() => {
const startOver = useCallback(() => {
// dispatch(canvasSessionTypeChanged({ type: 'simple' }));
$simpleId.set(null);
}, []);
return (
<Button size="sm" variant="link" alignSelf="stretch" onClick={startOver} px={2}>
Start Over
</Button>
);
});
StartOverButton.displayName = 'StartOverButton';

View File

@@ -1,4 +1,4 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { Divider, Flex, Heading } from '@invoke-ai/ui-library';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
import { ToolColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
@@ -29,6 +29,10 @@ export const CanvasToolbar = memo(() => {
return (
<Flex w="full" gap={2} alignItems="center" px={2}>
<Heading size="sm" me={2}>
Canvas
</Heading>
<Divider orientation="vertical" />
<ToolColorPicker />
<ToolSettings />
<Flex alignItems="center" h="full" flexGrow={1} justifyContent="flex-end">
@@ -44,6 +48,7 @@ export const CanvasToolbar = memo(() => {
<CanvasToolbarNewSessionMenuButton />
<CanvasSettingsPopover />
</Flex>
<Divider orientation="vertical" />
</Flex>
);
});

View File

@@ -2,7 +2,7 @@ import { IconButton } from '@invoke-ai/ui-library';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo, useCallback } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsOutBold } from 'react-icons/pi';
@@ -11,21 +11,17 @@ export const CanvasToolbarResetViewButton = memo(() => {
const canvasManager = useCanvasManager();
const isCanvasFocused = useIsRegionFocused('canvas');
const fitLayersToStage = useCallback(() => {
canvasManager.stage.fitLayersToStage();
}, [canvasManager.stage]);
useRegisteredHotkeys({
id: 'fitLayersToCanvas',
category: 'canvas',
callback: () => canvasManager.stage.fitLayersToStage(),
callback: canvasManager.stage.fitLayersToStage,
options: { enabled: isCanvasFocused, preventDefault: true },
dependencies: [isCanvasFocused],
});
useRegisteredHotkeys({
id: 'fitBboxToCanvas',
category: 'canvas',
callback: () => canvasManager.stage.fitBboxToStage(),
callback: canvasManager.stage.fitBboxToStage,
options: { enabled: isCanvasFocused, preventDefault: true },
dependencies: [isCanvasFocused],
});
@@ -62,7 +58,7 @@ export const CanvasToolbarResetViewButton = memo(() => {
<IconButton
tooltip={t('hotkeys.canvas.fitLayersToCanvas.title')}
aria-label={t('hotkeys.canvas.fitLayersToCanvas.title')}
onClick={fitLayersToStage}
onClick={canvasManager.stage.fitLayersToStage}
icon={<PiArrowsOutBold />}
variant="link"
alignSelf="stretch"

View File

@@ -19,7 +19,6 @@ 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

@@ -4,8 +4,8 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { useCallback } from 'react';
import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
export function useCanvasDeleteLayerHotkey() {
useAssertSingleton(useCanvasDeleteLayerHotkey.name);
@@ -13,18 +13,25 @@ export function useCanvasDeleteLayerHotkey() {
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isBusy = useCanvasIsBusy();
const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel);
const appTab = useAppSelector(selectActiveTab);
const deleteSelectedLayer = useCallback(() => {
if (selectedEntityIdentifier === null || isBusy || canvasRightPanelTab !== 'layers') {
if (selectedEntityIdentifier === null) {
return;
}
dispatch(entityDeleted({ entityIdentifier: selectedEntityIdentifier }));
}, [canvasRightPanelTab, dispatch, isBusy, selectedEntityIdentifier]);
}, [dispatch, selectedEntityIdentifier]);
const isDeleteEnabled = useMemo(
() => selectedEntityIdentifier !== null && !isBusy && canvasRightPanelTab === 'layers' && appTab === 'canvas',
[selectedEntityIdentifier, isBusy, canvasRightPanelTab, appTab]
);
useRegisteredHotkeys({
id: 'deleteSelected',
category: 'canvas',
callback: deleteSelectedLayer,
dependencies: [deleteSelectedLayer],
options: { enabled: isDeleteEnabled },
dependencies: [isDeleteEnabled, deleteSelectedLayer],
});
}

View File

@@ -154,36 +154,36 @@ export class CanvasStageModule extends CanvasModuleBase {
// If the stage _had_ no size just before this function was called, that means we've just mounted the stage or
// maybe un-hidden it. In that case, the user is about to see the stage for the first time, so we should fit the
// layers to the stage. If we don't do this, the layers will not be centered.
if (this.konva.stage.width() === 0 || this.konva.stage.height() === 0) {
// This fit must happen before the stage size is set, else we can end up with a brief flash of an incorrectly
// sized and scaled stage.
this.fitLayersToStage({ animate: false, targetWidth: containerWidth, targetHeight: containerHeight });
}
const shouldFitLayersAfterFittingStage = this.konva.stage.width() === 0 || this.konva.stage.height() === 0;
this.konva.stage.width(containerWidth);
this.konva.stage.height(containerHeight);
this.syncStageAttrs();
if (shouldFitLayersAfterFittingStage) {
this.fitLayersToStage();
}
};
/**
* Fits the bbox to the stage. This will center the bbox and scale it to fit the stage with some padding.
*/
fitBboxToStage = (options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => {
fitBboxToStage = (): void => {
const { rect } = this.manager.stateApi.getBbox();
this.log.trace({ rect }, 'Fitting bbox to stage');
this.fitRect(rect, options);
this.fitRect(rect);
};
/**
* Fits the visible canvas to the stage. This will center the canvas and scale it to fit the stage with some padding.
*/
fitLayersToStage = (options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => {
fitLayersToStage = (): void => {
const rect = this.manager.compositor.getVisibleRectOfType();
if (rect.width === 0 || rect.height === 0) {
this.fitBboxToStage(options);
this.fitBboxToStage();
} else {
this.log.trace({ rect }, 'Fitting layers to stage');
this.fitRect(rect, options);
this.fitRect(rect);
}
};
@@ -191,12 +191,12 @@ export class CanvasStageModule extends CanvasModuleBase {
* Fits the bbox and layers to the stage. The union of the bbox and the visible layers will be centered and scaled
* to fit the stage with some padding.
*/
fitBboxAndLayersToStage = (options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => {
fitBboxAndLayersToStage = (): void => {
const layersRect = this.manager.compositor.getVisibleRectOfType();
const bboxRect = this.manager.stateApi.getBbox().rect;
const unionRect = getRectUnion(layersRect, bboxRect);
this.log.trace({ bboxRect, layersRect, unionRect }, 'Fitting bbox and layers to stage');
this.fitRect(unionRect, options);
this.fitRect(unionRect);
};
/**
@@ -204,22 +204,16 @@ export class CanvasStageModule extends CanvasModuleBase {
*
* The max scale is 1, but the stage can be scaled down to fit the rect.
*/
fitRect = (rect: Rect, options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => {
const size = this.getSize();
const { animate, targetWidth, targetHeight } = {
animate: true,
targetWidth: size.width,
targetHeight: size.height,
...options,
};
fitRect = (rect: Rect): void => {
const { width, height } = this.getSize();
// If the stage has no size, we can't fit anything to it
if (targetWidth === 0 || targetHeight === 0) {
if (width === 0 || height === 0) {
return;
}
const availableWidth = targetWidth - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2;
const availableHeight = targetHeight - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2;
const availableWidth = width - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2;
const availableHeight = height - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2;
// Make sure we don't accidentally set the scale to something nonsensical, like a negative number, 0 or something
// outside the valid range
@@ -237,33 +231,23 @@ export class CanvasStageModule extends CanvasModuleBase {
this._intendedScale = scale;
this._activeSnapPoint = null;
if (animate) {
const tween = new Konva.Tween({
node: this.konva.stage,
duration: 0.15,
x,
y,
scaleX: scale,
scaleY: scale,
easing: Konva.Easings.EaseInOut,
onUpdate: () => {
this.syncStageAttrs();
},
onFinish: () => {
this.syncStageAttrs();
tween.destroy();
},
});
tween.play();
} else {
this.konva.stage.setAttrs({
x,
y,
scaleX: scale,
scaleY: scale,
});
this.syncStageAttrs();
}
const tween = new Konva.Tween({
node: this.konva.stage,
duration: 0.15,
x,
y,
scaleX: scale,
scaleY: scale,
easing: Konva.Easings.EaseInOut,
onUpdate: () => {
this.syncStageAttrs();
},
onFinish: () => {
this.syncStageAttrs();
tween.destroy();
},
});
tween.play();
};
/**

View File

@@ -1,5 +1,5 @@
import { Mutex } from 'async-mutex';
import type { ProgressData, ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context';
import type { 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) => {
const cb = (selectedItemId: number | null, progressData: Record<number, ProgressData | undefined>) => {
connectToSession = ($selectedItemId: Atom<number | null>, $progressData: ProgressDataMap) =>
effect([$selectedItemId, $progressData], (selectedItemId, progressData) => {
if (!selectedItemId) {
this.$imageSrc.set(null);
return;
@@ -153,14 +153,7 @@ 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.image_names.length })}2`}
title={`${t('gallery.deleteImage', { count: state.imageDTOs.length })}2`}
isOpen={state.isOpen}
onClose={api.close}
cancelButtonText={t('common.cancel')}

View File

@@ -19,16 +19,17 @@ 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, intersection, some } from 'lodash-es';
import { forEach, intersectionBy, 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 = {
image_names: string[];
imageDTOs: ImageDTO[];
usagePerImage: ImageUsage[];
usageSummary: ImageUsage;
isOpen: boolean;
@@ -37,7 +38,7 @@ type DeleteImagesModalState = {
};
const getInitialState = (): DeleteImagesModalState => ({
image_names: [],
imageDTOs: [],
usagePerImage: [],
usageSummary: {
isControlLayerImage: false,
@@ -53,21 +54,21 @@ const getInitialState = (): DeleteImagesModalState => ({
const $deleteModalState = atom<DeleteImagesModalState>(getInitialState());
const deleteImagesWithDialog = async (image_names: string[]): Promise<void> => {
const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise<void> => {
const { getState, dispatch } = getStore();
const imageUsage = getImageUsageFromImageNames(image_names, getState());
const imageUsage = getImageUsageFromImageDTOs(imageDTOs, 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(image_names, dispatch, getState);
await handleDeletions(imageDTOs, dispatch, getState);
}
return new Promise<void>((resolve, reject) => {
$deleteModalState.set({
usagePerImage: imageUsage,
usageSummary: getImageUsageSummary(imageUsage),
image_names,
imageDTOs,
isOpen: true,
resolve,
reject,
@@ -75,12 +76,12 @@ const deleteImagesWithDialog = async (image_names: string[]): Promise<void> => {
});
};
const handleDeletions = async (image_names: string[], dispatch: AppDispatch, getState: AppGetState) => {
const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, getState: AppGetState) => {
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
if (intersection(state.gallery.selection, image_names).length > 0) {
if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').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);
@@ -92,11 +93,11 @@ const handleDeletions = async (image_names: string[], 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 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);
for (const imageDTO of imageDTOs) {
deleteNodesImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
deleteReferenceImages(state, dispatch, imageDTO);
deleteRasterLayerImages(state, dispatch, imageDTO);
}
} catch {
// no-op
@@ -105,7 +106,7 @@ const handleDeletions = async (image_names: string[], dispatch: AppDispatch, get
const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => {
const state = $deleteModalState.get();
await handleDeletions(state.image_names, dispatch, getState);
await handleDeletions(state.imageDTOs, dispatch, getState);
state.resolve?.();
closeSilently();
};
@@ -141,8 +142,8 @@ export const useDeleteImageModalApi = () => {
return api;
};
const getImageUsageFromImageNames = (image_names: string[], state: RootState): ImageUsage[] => {
if (image_names.length === 0) {
const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): ImageUsage[] => {
if (imageDTOs.length === 0) {
return [];
}
@@ -151,7 +152,7 @@ const getImageUsageFromImageNames = (image_names: string[], state: RootState): I
const upscale = selectUpscaleSlice(state);
const refImages = selectRefImagesSlice(state);
return image_names.map((image_name) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, refImages, image_name));
};
const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({
@@ -177,7 +178,7 @@ const isAnyImageInUse = (imageUsage: ImageUsage[]): boolean =>
);
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const actions: Param0<typeof dispatch>[] = [];
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
@@ -185,7 +186,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name:
}
forEach(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input) && input.value?.image_name === image_name) {
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
actions.push(
fieldImageValueChanged({
nodeId: node.data.id,
@@ -200,7 +201,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name:
fieldImageCollectionValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: input.value?.filter((value) => value?.image_name !== image_name),
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
})
);
}
@@ -210,11 +211,11 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name:
actions.forEach(dispatch);
};
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
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 === image_name) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}
@@ -225,19 +226,19 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image
});
};
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectReferenceImageEntities(state).forEach((entity) => {
if (entity.config.image?.image_name === image_name) {
if (entity.config.image?.image_name === imageDTO.image_name) {
dispatch(refImageImageChanged({ id: entity.id, imageDTO: null }));
}
});
};
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
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 === image_name) {
if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}

View File

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

View File

@@ -8,6 +8,7 @@ import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreview
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
import { firefoxDndFix } from 'features/dnd/util';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
@@ -47,6 +48,9 @@ export const DndImage = memo(
getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name),
onDragStart: () => {
setIsDragging(true);
if ($imageViewer.get()) {
$imageViewer.set(false);
}
},
onDrop: () => {
setIsDragging(false);

View File

@@ -4,6 +4,7 @@ import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/exter
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Heading } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { getStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { $focusedRegion } from 'common/hooks/focus';
@@ -11,6 +12,7 @@ import { useClientSideUpload } from 'common/hooks/useClientSideUpload';
import { setFileToPaste } from 'features/controlLayers/components/CanvasPasteModal';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import type { DndTargetState } from 'features/dnd/types';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
@@ -68,6 +70,7 @@ export const FullscreenDropzone = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const [dndState, setDndState] = useState<DndTargetState>('idle');
const activeTab = useAppSelector(selectActiveTab);
const isImageViewerOpen = useStore($imageViewer);
const isClientSideUploadEnabled = useAppSelector(selectIsClientSideUploadEnabled);
const clientSideUpload = useClientSideUpload();
@@ -93,7 +96,13 @@ export const FullscreenDropzone = memo(() => {
// While on the canvas tab and when pasting a single image, canvas may want to create a new layer. Let it handle
// the paste event.
const [firstImageFile] = files;
if (focusedRegion === 'canvas' && activeTab === 'canvas' && files.length === 1 && firstImageFile) {
if (
focusedRegion === 'canvas' &&
!isImageViewerOpen &&
activeTab === 'canvas' &&
files.length === 1 &&
firstImageFile
) {
setFileToPaste(firstImageFile);
return;
}
@@ -116,7 +125,7 @@ export const FullscreenDropzone = memo(() => {
uploadImages(uploadArgs);
}
},
[activeTab, t, isClientSideUploadEnabled, clientSideUpload]
[activeTab, isImageViewerOpen, t, isClientSideUploadEnabled, clientSideUpload]
);
const onPaste = useCallback(

View File

@@ -87,7 +87,7 @@ const _multipleImage = buildTypeAndKey('multiple-image');
export type MultipleImageDndSourceData = DndData<
typeof _multipleImage.type,
typeof _multipleImage.key,
{ image_names: string[]; board_id: BoardId }
{ imageDTOs: ImageDTO[]; boardId: 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.image_names.map((image_name) => ({ image_name })));
newValue.push(...sourceData.payload.imageDTOs.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) {
if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) {
return false;
}
if (sourceData.payload.imageDTO.image_name === secondImage) {
if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) {
return false;
}
return true;
},
handler: ({ sourceData, dispatch }) => {
const { imageDTO } = sourceData.payload;
setComparisonImage({ image_name: imageDTO.image_name, dispatch });
setComparisonImage({ imageDTO, dispatch });
},
};
//#endregion
@@ -450,7 +450,7 @@ export const addImageToBoardDndTarget: DndTarget<
return currentBoard !== destinationBoard;
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
const currentBoard = sourceData.payload.boardId;
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({ image_names: [imageDTO.image_name], boardId, dispatch });
addImagesToBoard({ imageDTOs: [imageDTO], boardId, dispatch });
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const { image_names } = sourceData.payload;
const { imageDTOs } = sourceData.payload;
const { boardId } = targetData.payload;
addImagesToBoard({ image_names, boardId, dispatch });
addImagesToBoard({ imageDTOs, boardId, dispatch });
}
},
};
@@ -494,7 +494,7 @@ export const removeImageFromBoardDndTarget: DndTarget<
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
const currentBoard = sourceData.payload.boardId;
return currentBoard !== 'none';
}
@@ -503,12 +503,12 @@ export const removeImageFromBoardDndTarget: DndTarget<
handler: ({ sourceData, dispatch }) => {
if (singleImageDndSource.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
removeImagesFromBoard({ image_names: [imageDTO.image_name], dispatch });
removeImagesFromBoard({ imageDTOs: [imageDTO], dispatch });
}
if (multipleImageDndSource.typeGuard(sourceData)) {
const { image_names } = sourceData.payload;
removeImagesFromBoard({ image_names, dispatch });
const { imageDTOs } = sourceData.payload;
removeImagesFromBoard({ imageDTOs, dispatch });
}
},
};

View File

@@ -1,6 +1,5 @@
import { IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { buildUseDisclosure } from 'common/hooks/useBoolean';
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import type { ChangeEvent, KeyboardEvent } from 'react';
@@ -8,8 +7,6 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
export const [useBoardSearchDisclosure, $boardSearchIsOpen] = buildUseDisclosure(false);
export const BoardsSearch = memo(() => {
const dispatch = useAppDispatch();
const boardSearchText = useAppSelector(selectBoardSearchText);

View File

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

View File

@@ -1,26 +1,25 @@
import type { UseDisclosureReturn } from '@invoke-ai/ui-library';
import { Box, Collapse, Divider, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { BoardsListWrapper } from 'features/gallery/components/Boards/BoardsList/BoardsListWrapper';
import { $boardSearchIsOpen, BoardsSearch } from 'features/gallery/components/Boards/BoardsList/BoardsSearch';
import { GalleryTopBar } from 'features/gallery/components/GalleryTopBar';
import { BoardsSearch } from 'features/gallery/components/Boards/BoardsList/BoardsSearch';
import type { CSSProperties } from 'react';
import { memo } from 'react';
const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 };
export const BoardsPanel = memo(() => {
const boardSearchDisclosure = useStore($boardSearchIsOpen);
return (
<Flex flexDir="column" w="full" h="full" p={2}>
<GalleryTopBar />
<Collapse in={boardSearchDisclosure} style={COLLAPSE_STYLES}>
<Box w="full" pt={2}>
<BoardsSearch />
</Box>
</Collapse>
<Divider pt={2} />
<BoardsListWrapper />
</Flex>
);
});
BoardsPanel.displayName = 'BoardsPanel';
export const BoardsListPanelContent = memo(
({ boardSearchDisclosure }: { boardSearchDisclosure: UseDisclosureReturn }) => {
return (
<Flex flexDir="column" w="full" h="full">
<Collapse in={boardSearchDisclosure.isOpen} style={COLLAPSE_STYLES}>
<Box w="full" pt={2}>
<BoardsSearch />
</Box>
</Collapse>
<Divider pt={2} />
<BoardsListWrapper />
</Flex>
);
}
);
BoardsListPanelContent.displayName = 'BoardsListPanelContent';

View File

@@ -25,8 +25,9 @@ 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',
@@ -45,7 +46,7 @@ const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '10
const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView);
const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm);
export const GalleryPanel = memo(() => {
export const Gallery = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const galleryView = useAppSelector(selectGalleryView);
@@ -69,8 +70,8 @@ export const GalleryPanel = memo(() => {
const boardName = useBoardName(selectedBoardId);
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" pb={2}>
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full" pt={1} minH={0}>
<Tabs index={galleryView === 'images' ? 0 : 1} variant="enclosed" display="flex" flexDir="column" w="full">
<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}
@@ -89,7 +90,6 @@ export const GalleryPanel = memo(() => {
<Flex h="full" justifyContent="flex-end">
<GalleryUploadButton />
<GallerySettingsPopover />
<IconButton
size="sm"
variant="link"
@@ -101,21 +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>
{/* <GalleryImageGrid />
<GalleryPagination /> */}
<NewGallery />
<Collapse in={searchDisclosure.isOpen} style={COLLAPSE_STYLES}>
<Box w="full" pt={2}>
<GallerySearch
searchTerm={searchTerm}
onChangeSearchTerm={onChangeSearchTerm}
onResetSearchTerm={onResetSearchTerm}
/>
</Box>
</Collapse>
<GalleryImageGrid />
<GalleryPagination />
</Flex>
);
});
GalleryPanel.displayName = 'Gallery';
Gallery.displayName = 'Gallery';

View File

@@ -1,51 +1,67 @@
import { Flex, IconButton, Text } from '@invoke-ai/ui-library';
import type { UseDisclosureReturn } from '@invoke-ai/ui-library';
import { Button, Flex, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useBoardSearchDisclosure } from 'features/gallery/components/Boards/BoardsList/BoardsSearch';
import { BoardsSettingsPopover } from 'features/gallery/components/Boards/BoardsSettingsPopover';
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import type { UsePanelReturn } from 'features/ui/hooks/usePanel';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiMagnifyingGlassBold } from 'react-icons/pi';
import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi';
export const GalleryTopBar = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const boardSearchText = useAppSelector(selectBoardSearchText);
const boardSearchDisclosure = useBoardSearchDisclosure();
export const GalleryTopBar = memo(
({
boardsListPanel,
boardSearchDisclosure,
}: {
boardsListPanel: UsePanelReturn;
boardSearchDisclosure: UseDisclosureReturn;
}) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const boardSearchText = useAppSelector(selectBoardSearchText);
const onClickBoardSearch = useCallback(() => {
if (boardSearchText.length) {
dispatch(boardSearchTextChanged(''));
}
boardSearchDisclosure.toggle();
}, [boardSearchText.length, boardSearchDisclosure, dispatch]);
const onClickBoardSearch = useCallback(() => {
if (boardSearchText.length) {
dispatch(boardSearchTextChanged(''));
}
boardSearchDisclosure.onToggle();
boardsListPanel.expand();
}, [boardSearchText.length, boardSearchDisclosure, boardsListPanel, dispatch]);
return (
<Flex alignItems="center" justifyContent="space-between" w="full">
<Flex flexGrow={1} flexBasis={0}>
<Text>Boards</Text>
return (
<Flex alignItems="center" justifyContent="space-between" w="full">
<Flex flexGrow={1} flexBasis={0}>
<Button
size="sm"
variant="ghost"
onClick={boardsListPanel.toggle}
rightIcon={boardsListPanel.isCollapsed ? <PiCaretDownBold /> : <PiCaretUpBold />}
>
{boardsListPanel.isCollapsed ? t('boards.viewBoards') : t('boards.hideBoards')}
</Button>
</Flex>
<Flex>
<GalleryHeader />
</Flex>
<Flex flexGrow={1} flexBasis={0} justifyContent="flex-end">
<BoardsSettingsPopover />
<IconButton
size="sm"
variant="link"
alignSelf="stretch"
onClick={onClickBoardSearch}
tooltip={
boardSearchDisclosure.isOpen ? `${t('gallery.exitBoardSearch')}` : `${t('gallery.displayBoardSearch')}`
}
aria-label={t('gallery.displayBoardSearch')}
icon={<PiMagnifyingGlassBold />}
colorScheme={boardSearchDisclosure.isOpen ? 'invokeBlue' : 'base'}
/>
</Flex>
</Flex>
<Flex>
<GalleryHeader />
</Flex>
<Flex flexGrow={1} flexBasis={0} justifyContent="flex-end">
<BoardsSettingsPopover />
<IconButton
size="sm"
variant="link"
alignSelf="stretch"
onClick={onClickBoardSearch}
tooltip={
boardSearchDisclosure.isOpen ? `${t('gallery.exitBoardSearch')}` : `${t('gallery.displayBoardSearch')}`
}
aria-label={t('gallery.displayBoardSearch')}
icon={<PiMagnifyingGlassBold />}
colorScheme={boardSearchDisclosure.isOpen ? 'invokeBlue' : 'base'}
/>
</Flex>
</Flex>
);
});
);
}
);
GalleryTopBar.displayName = 'GalleryTopBar';

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { newCanvasFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
@@ -15,51 +16,56 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const subMenu = useSubMenu();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusySafe();
const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiPlusBold />}>

View File

@@ -3,6 +3,7 @@ import { useAppStore } from 'app/store/nanostores/store';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { sentImageToCanvas } from 'features/gallery/store/actions';
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
@@ -17,6 +18,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const subMenu = useSubMenu();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusySafe();
const onClickNewRasterLayerFromImage = useCallback(() => {
@@ -24,60 +26,65 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
const onClickNewControlLayerFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
const onClickNewInpaintMaskFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
const onClickNewRegionalGuidanceFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
const onClickNewRegionalReferenceImageFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiPlusBold />}>

View File

@@ -1,20 +1,17 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsOutBold } from 'react-icons/pi';
export const ImageMenuItemOpenInViewer = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO.image_name));
// TODO: figure out how to select the closest image viewer...
}, [dispatch, imageDTO]);
imageViewer.openImageInViewer(imageDTO);
}, [imageDTO, imageViewer]);
return (
<IconMenuItem

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useAppStore } from 'app/store/nanostores/store';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
@@ -13,18 +14,20 @@ export const ImageMenuItemUseAsRefImage = memo(() => {
const { t } = useTranslation();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const onClickNewGlobalReferenceImageFromImage = useCallback(() => {
const { dispatch, getState } = store;
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, store, t]);
}, [imageDTO, imageViewer, store, t]);
return (
<MenuItem icon={<PiImageBold />} onClickCapture={onClickNewGlobalReferenceImageFromImage}>

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 } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold, PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi';
import {
@@ -37,25 +37,37 @@ const MultipleSelectionMenuItems = () => {
}, [deleteImageModal, selection]);
const handleStarSelection = useCallback(() => {
starImages({ image_names: selection });
starImages({ imageDTOs: selection });
}, [starImages, selection]);
const handleUnstarSelection = useCallback(() => {
unstarImages({ image_names: selection });
unstarImages({ imageDTOs: selection });
}, [unstarImages, selection]);
const handleBulkDownload = useCallback(() => {
bulkDownload({ image_names: selection });
bulkDownload({ image_names: selection.map((img) => img.image_name) });
}, [selection, bulkDownload]);
const areAllStarred = useMemo(() => {
return selection.every((img) => img.starred);
}, [selection]);
const areAllUnstarred = useMemo(() => {
return selection.every((img) => !img.starred);
}, [selection]);
return (
<>
<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>
{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>
)}
{isBulkDownloadEnabled && (
<MenuItem icon={<PiDownloadSimpleBold />} onClickCapture={handleBulkDownload}>
{t('gallery.downloadSelection')}

View File

@@ -15,9 +15,8 @@ import { firefoxDndFix } from 'features/dnd/util';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
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';
@@ -85,7 +84,6 @@ interface Props {
export const GalleryImage = memo(({ imageDTO }: Props) => {
const store = useAppStore();
const autoLayoutContext = useAutoLayoutContext();
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewState, setDragPreviewState] = useState<
DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null
@@ -93,7 +91,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
const ref = useRef<HTMLImageElement>(null);
const dndId = useId();
const selectIsSelectedForCompare = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare === imageDTO.image_name),
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name),
[imageDTO.image_name]
);
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
@@ -101,7 +99,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
() =>
createSelector(selectGallerySlice, (gallery) => {
for (const selectedImage of gallery.selection) {
if (selectedImage === imageDTO.image_name) {
if (selectedImage.image_name === imageDTO.image_name) {
return true;
}
}
@@ -126,11 +124,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({
image_names: gallery.selection,
board_id: gallery.selectedBoardId,
imageDTOs: gallery.selection,
boardId: gallery.selectedBoardId,
});
}
@@ -167,10 +165,7 @@ 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.image_names.includes(imageDTO.image_name)
) {
if (multipleImageDndSource.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) {
setIsDragging(true);
}
},
@@ -196,7 +191,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
(e) => {
store.dispatch(
galleryImageClicked({
imageName: imageDTO.image_name,
imageDTO,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
@@ -208,9 +203,11 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
);
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(() => {
// Use the atom here directly instead of the `useImageViewer` to avoid re-rendering the gallery when the viewer
// opened state changes.
$imageViewer.set(true);
store.dispatch(imageToCompareChanged(null));
autoLayoutContext.focusPanel(VIEWER_PANEL_ID);
}, [autoLayoutContext, store]);
}, [store]);
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);

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