Compare commits

...

121 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
psychedelicious
85f98ab3eb fix(app): error on upload + resize for unusual image modes 2025-06-11 11:18:08 +10:00
Mary Hipp
dac75685be disable publish and cancel buttons once it begins 2025-06-10 19:50:09 -04:00
psychedelicious
d7b5a8b298 fix: opencv dependency conflict (#8095)
* build: prevent `opencv-python` from being installed

Fixes this error: `AttributeError: module 'cv2.ximgproc' has no attribute 'thinning'`

`opencv-contrib-python` supersedes `opencv-python`, providing the same API + additional features. The two packages should not be installed at the same time to avoid conflicts and/or errors.

The `invisible-watermark` package requires `opencv-python`, but we require the contrib variant.

This change updates `pyproject.toml` to prevent `opencv-python` from ever being installed using a `uv` features called dependency overrides.

* feat(ui): data viewer supports disabling wrap

* feat(api): list _all_ pkgs in app deps endpoint

* chore(ui): typegen

* feat(ui): update about modal to display new full deps list

* chore: uv lock
2025-06-10 08:33:41 -04:00
Kent Keirsey
d3ecaa740f Add Precise Reference to Starter Models 2025-06-09 22:02:11 +10:00
dunkeroni
b5a6765a3d also search image creation date 2025-06-09 21:54:26 +10:00
psychedelicious
3704573ef8 chore: bump version to v5.14.0 2025-06-06 22:36:32 +10:00
Hiroto N
01fbf2ce4d translationBot(ui): update translation (Japanese)
Currently translated at 76.5% (1467 of 1917 strings)

Co-authored-by: Hiroto N <hironow365@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ja/
Translation: InvokeAI/Web UI
2025-06-06 20:56:13 +10:00
Riccardo Giovanetti
96e7003449 translationBot(ui): update translation (Italian)
Currently translated at 98.9% (1896 of 1917 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2025-06-06 20:56:13 +10:00
RyoKoba
80197b8856 translationBot(ui): update translation (Japanese)
Currently translated at 76.1% (1460 of 1917 strings)

Co-authored-by: RyoKoba <kobayashi_ryo@cyberagent.co.jp>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ja/
Translation: InvokeAI/Web UI
2025-06-06 20:52:36 +10:00
Hosted Weblate
0187bc671e translationBot(ui): update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/
Translation: InvokeAI/Web UI
2025-06-06 20:52:36 +10:00
psychedelicious
31584daabe feat(ui): display canvas spinner during compositing operations 2025-06-06 20:50:02 +10:00
psychedelicious
a6cb522fed feat(ui): add bboxUpdated callback to transformer, use it to fit layer to stage when creating new canvas from an image
When a layer is initialized, we do not yet know its bbox, so we cannot fit the stage view to the layer. We have to wait for the bbox calculation to finish. Previously, we had no way to wait unti lthat bbox calculation was complete to take an action.

For example, this means we could not fit the layers to the stage immediately after creating a new layer, bc we don't know the dimensions of the layer yet.

This callback lets us do that. When creating a new canvas from an image, we now...
- Register a bbox update callback to fit the layers to stage
- Layer is created
- Canvas initializes the layer's entity adapter module (layer's width and height are set to zero at this point)
- Canvas calculates the bbox
- Bbox is updated (width and height are now correct)
- Callback is ran, fitting layer to stage
2025-06-06 20:50:02 +10:00
psychedelicious
f70be1e415 feat(ui): animate stage fit operations (e.g. fit layers to stage) 2025-06-06 20:50:02 +10:00
psychedelicious
a2901f2b46 feat(ui): add method to stage to fit to union of bbox and layers
This ensures that _both_ bbox and layers are visible
2025-06-06 20:50:02 +10:00
psychedelicious
b61c66c3a9 feat(ui): add spinner indicator to canvas during rasterizing operations and while pending rect calculations 2025-06-06 20:50:02 +10:00
psychedelicious
c77f9ec202 feat(ui): add hook to get all entity adapters in array 2025-06-06 20:50:02 +10:00
psychedelicious
2c5c35647f fix(ui): new canvas from image places image in bbox correctly 2025-06-06 20:50:02 +10:00
dunkeroni
bf0fdbd10e Fix: inpaint model mask using wrong tensor name 2025-06-05 11:31:35 -04:00
339 changed files with 7682 additions and 5875 deletions

View File

@@ -1,8 +1,7 @@
import typing
from enum import Enum
from importlib.metadata import PackageNotFoundError, version
from importlib.metadata import distributions
from pathlib import Path
from platform import python_version
from typing import Optional
import torch
@@ -44,24 +43,6 @@ class AppVersion(BaseModel):
highlights: Optional[list[str]] = Field(default=None, description="Highlights of release")
class AppDependencyVersions(BaseModel):
"""App depencency Versions Response"""
accelerate: str = Field(description="accelerate version")
compel: str = Field(description="compel version")
cuda: Optional[str] = Field(description="CUDA version")
diffusers: str = Field(description="diffusers version")
numpy: str = Field(description="Numpy version")
opencv: str = Field(description="OpenCV version")
onnx: str = Field(description="ONNX version")
pillow: str = Field(description="Pillow (PIL) version")
python: str = Field(description="Python version")
torch: str = Field(description="PyTorch version")
torchvision: str = Field(description="PyTorch Vision version")
transformers: str = Field(description="transformers version")
xformers: Optional[str] = Field(description="xformers version")
class AppConfig(BaseModel):
"""App Config Response"""
@@ -76,27 +57,19 @@ async def get_version() -> AppVersion:
return AppVersion(version=__version__)
@app_router.get("/app_deps", operation_id="get_app_deps", status_code=200, response_model=AppDependencyVersions)
async def get_app_deps() -> AppDependencyVersions:
@app_router.get("/app_deps", operation_id="get_app_deps", status_code=200, response_model=dict[str, str])
async def get_app_deps() -> dict[str, str]:
deps: dict[str, str] = {dist.metadata["Name"]: dist.version for dist in distributions()}
try:
xformers = version("xformers")
except PackageNotFoundError:
xformers = None
return AppDependencyVersions(
accelerate=version("accelerate"),
compel=version("compel"),
cuda=torch.version.cuda,
diffusers=version("diffusers"),
numpy=version("numpy"),
opencv=version("opencv-python"),
onnx=version("onnx"),
pillow=version("pillow"),
python=python_version(),
torch=torch.version.__version__,
torchvision=version("torchvision"),
transformers=version("transformers"),
xformers=xformers,
)
cuda = torch.version.cuda or "N/A"
except Exception:
cuda = "N/A"
deps["CUDA"] = cuda
sorted_deps = dict(sorted(deps.items(), key=lambda item: item[0].lower()))
return sorted_deps
@app_router.get("/config", operation_id="get_config", status_code=200, response_model=AppConfig)

View File

@@ -99,7 +99,9 @@ async def upload_image(
raise HTTPException(status_code=400, detail="Invalid resize_to format or size")
try:
np_image = pil_to_np(pil_image)
# heuristic_resize_fast expects an RGB or RGBA image
pil_rgba = pil_image.convert("RGBA")
np_image = pil_to_np(pil_rgba)
np_image = heuristic_resize_fast(np_image, (resize_dims.width, resize_dims.height))
pil_image = np_to_pil(np_image)
except Exception:

View File

@@ -14,13 +14,14 @@ from invokeai.app.services.session_queue.session_queue_common import (
CancelByBatchIDsResult,
CancelByDestinationResult,
ClearResult,
DeleteAllExceptCurrentResult,
DeleteByDestinationResult,
EnqueueBatchResult,
FieldIdentifier,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
SessionQueueItem,
SessionQueueItemDTO,
SessionQueueStatus,
)
from invokeai.app.services.shared.pagination import CursorPaginatedResults
@@ -68,7 +69,7 @@ async def enqueue_batch(
"/{queue_id}/list",
operation_id="list_queue_items",
responses={
200: {"model": CursorPaginatedResults[SessionQueueItemDTO]},
200: {"model": CursorPaginatedResults[SessionQueueItem]},
},
)
async def list_queue_items(
@@ -77,11 +78,36 @@ async def list_queue_items(
status: Optional[QUEUE_ITEM_STATUS] = Query(default=None, description="The status of items to fetch"),
cursor: Optional[int] = Query(default=None, description="The pagination cursor"),
priority: int = Query(default=0, description="The pagination cursor priority"),
) -> CursorPaginatedResults[SessionQueueItemDTO]:
"""Gets all queue items (without graphs)"""
destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"),
) -> CursorPaginatedResults[SessionQueueItem]:
"""Gets cursor-paginated queue items"""
return ApiDependencies.invoker.services.session_queue.list_queue_items(
queue_id=queue_id, limit=limit, status=status, cursor=cursor, priority=priority
queue_id=queue_id,
limit=limit,
status=status,
cursor=cursor,
priority=priority,
destination=destination,
)
@session_queue_router.get(
"/{queue_id}/list_all",
operation_id="list_all_queue_items",
responses={
200: {"model": list[SessionQueueItem]},
},
)
async def list_all_queue_items(
queue_id: str = Path(description="The queue id to perform this operation on"),
destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"),
) -> list[SessionQueueItem]:
"""Gets all queue items"""
return ApiDependencies.invoker.services.session_queue.list_all_queue_items(
queue_id=queue_id,
destination=destination,
)
@@ -121,6 +147,18 @@ async def cancel_all_except_current(
return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(queue_id=queue_id)
@session_queue_router.put(
"/{queue_id}/delete_all_except_current",
operation_id="delete_all_except_current",
responses={200: {"model": DeleteAllExceptCurrentResult}},
)
async def delete_all_except_current(
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> DeleteAllExceptCurrentResult:
"""Immediately deletes all queue items except in-processing items"""
return ApiDependencies.invoker.services.session_queue.delete_all_except_current(queue_id=queue_id)
@session_queue_router.put(
"/{queue_id}/cancel_by_batch_ids",
operation_id="cancel_by_batch_ids",
@@ -269,6 +307,18 @@ async def get_queue_item(
return ApiDependencies.invoker.services.session_queue.get_queue_item(item_id)
@session_queue_router.delete(
"/{queue_id}/i/{item_id}",
operation_id="delete_queue_item",
)
async def delete_queue_item(
queue_id: str = Path(description="The queue id to perform this operation on"),
item_id: int = Path(description="The queue item to delete"),
) -> None:
"""Deletes a queue item"""
ApiDependencies.invoker.services.session_queue.delete_queue_item(item_id)
@session_queue_router.put(
"/{queue_id}/i/{item_id}/cancel",
operation_id="cancel_queue_item",
@@ -298,3 +348,18 @@ async def counts_by_destination(
return ApiDependencies.invoker.services.session_queue.get_counts_by_destination(
queue_id=queue_id, destination=destination
)
@session_queue_router.delete(
"/{queue_id}/d/{destination}",
operation_id="delete_by_destination",
responses={200: {"model": DeleteByDestinationResult}},
)
async def delete_by_destination(
queue_id: str = Path(description="The queue id to query"),
destination: str = Path(description="The destination to query"),
) -> DeleteByDestinationResult:
"""Deletes all items with the given destination"""
return ApiDependencies.invoker.services.session_queue.delete_by_destination(
queue_id=queue_id, destination=destination
)

View File

@@ -184,7 +184,7 @@ class CreateGradientMaskInvocation(BaseInvocation):
main_model_config = context.models.get_config(self.unet.unet.key)
assert isinstance(main_model_config, MainConfigBase)
if main_model_config.variant is ModelVariantType.Inpaint:
mask = mask_tensor
mask = dilated_mask_tensor
vae_info: LoadedModel = context.models.load(self.vae.vae)
image = context.images.get_pil(self.image.image_name)
image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))

View File

@@ -196,9 +196,13 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
# Search term condition
if search_term:
query_conditions += """--sql
AND images.metadata LIKE ?
AND (
images.metadata LIKE ?
OR images.created_at LIKE ?
)
"""
query_params.append(f"%{search_term.lower()}%")
query_params.append(f"%{search_term.lower()}%")
if starred_first:
query_pagination = f"""--sql

View File

@@ -10,6 +10,8 @@ from invokeai.app.services.session_queue.session_queue_common import (
CancelByDestinationResult,
CancelByQueueIDResult,
ClearResult,
DeleteAllExceptCurrentResult,
DeleteByDestinationResult,
EnqueueBatchResult,
IsEmptyResult,
IsFullResult,
@@ -17,7 +19,6 @@ from invokeai.app.services.session_queue.session_queue_common import (
RetryItemsResult,
SessionQueueCountsByDestination,
SessionQueueItem,
SessionQueueItemDTO,
SessionQueueStatus,
)
from invokeai.app.services.shared.graph import GraphExecutionState
@@ -92,6 +93,11 @@ class SessionQueueBase(ABC):
"""Cancels a session queue item"""
pass
@abstractmethod
def delete_queue_item(self, item_id: int) -> None:
"""Deletes a session queue item"""
pass
@abstractmethod
def fail_queue_item(
self, item_id: int, error_type: str, error_message: str, error_traceback: str
@@ -109,6 +115,11 @@ class SessionQueueBase(ABC):
"""Cancels all queue items with the given batch destination"""
pass
@abstractmethod
def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult:
"""Deletes all queue items with the given batch destination"""
pass
@abstractmethod
def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
"""Cancels all queue items with matching queue ID"""
@@ -119,6 +130,11 @@ class SessionQueueBase(ABC):
"""Cancels all queue items except in-progress items"""
pass
@abstractmethod
def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult:
"""Deletes all queue items except in-progress items"""
pass
@abstractmethod
def list_queue_items(
self,
@@ -127,10 +143,20 @@ class SessionQueueBase(ABC):
priority: int,
cursor: Optional[int] = None,
status: Optional[QUEUE_ITEM_STATUS] = None,
) -> CursorPaginatedResults[SessionQueueItemDTO]:
destination: Optional[str] = None,
) -> CursorPaginatedResults[SessionQueueItem]:
"""Gets a page of session queue items"""
pass
@abstractmethod
def list_all_queue_items(
self,
queue_id: str,
destination: Optional[str] = None,
) -> list[SessionQueueItem]:
"""Gets all queue items that match the given parameters"""
pass
@abstractmethod
def get_queue_item(self, item_id: int) -> SessionQueueItem:
"""Gets a session queue item by ID"""

View File

@@ -207,7 +207,7 @@ class FieldIdentifier(BaseModel):
field_name: str = Field(description="The name of the field")
class SessionQueueItemWithoutGraph(BaseModel):
class SessionQueueItem(BaseModel):
"""Session queue item without the full graph. Used for serialization."""
item_id: int = Field(description="The identifier of the session queue item")
@@ -251,42 +251,7 @@ class SessionQueueItemWithoutGraph(BaseModel):
default=None,
description="The ID of the published workflow associated with this queue item",
)
api_input_fields: Optional[list[FieldIdentifier]] = Field(
default=None, description="The fields that were used as input to the API"
)
api_output_fields: Optional[list[FieldIdentifier]] = Field(
default=None, description="The nodes that were used as output from the API"
)
credits: Optional[float] = Field(default=None, description="The total credits used for this queue item")
@classmethod
def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO":
# must parse these manually
queue_item_dict["field_values"] = get_field_values(queue_item_dict)
return SessionQueueItemDTO(**queue_item_dict)
model_config = ConfigDict(
json_schema_extra={
"required": [
"item_id",
"status",
"batch_id",
"queue_id",
"session_id",
"priority",
"session_id",
"created_at",
"updated_at",
]
}
)
class SessionQueueItemDTO(SessionQueueItemWithoutGraph):
pass
class SessionQueueItem(SessionQueueItemWithoutGraph):
session: GraphExecutionState = Field(description="The fully-populated session to be executed")
workflow: Optional[WorkflowWithoutID] = Field(
default=None, description="The workflow associated with this queue item"
@@ -397,6 +362,18 @@ class CancelByDestinationResult(CancelByBatchIDsResult):
pass
class DeleteByDestinationResult(BaseModel):
"""Result of deleting by a destination"""
deleted: int = Field(..., description="Number of queue items deleted")
class DeleteAllExceptCurrentResult(DeleteByDestinationResult):
"""Result of deleting all except current"""
pass
class CancelByQueueIDResult(CancelByBatchIDsResult):
"""Result of canceling by queue id"""

View File

@@ -17,6 +17,8 @@ from invokeai.app.services.session_queue.session_queue_common import (
CancelByDestinationResult,
CancelByQueueIDResult,
ClearResult,
DeleteAllExceptCurrentResult,
DeleteByDestinationResult,
EnqueueBatchResult,
IsEmptyResult,
IsFullResult,
@@ -24,7 +26,6 @@ from invokeai.app.services.session_queue.session_queue_common import (
RetryItemsResult,
SessionQueueCountsByDestination,
SessionQueueItem,
SessionQueueItemDTO,
SessionQueueItemNotFoundError,
SessionQueueStatus,
ValueToInsertTuple,
@@ -46,10 +47,6 @@ class SqliteSessionQueue(SessionQueueBase):
clear_result = self.clear(DEFAULT_QUEUE_ID)
if clear_result.deleted > 0:
self.__invoker.services.logger.info(f"Cleared all {clear_result.deleted} queue items")
else:
prune_result = self.prune(DEFAULT_QUEUE_ID)
if prune_result.deleted > 0:
self.__invoker.services.logger.info(f"Pruned {prune_result.deleted} finished queue items")
def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
@@ -220,6 +217,19 @@ class SqliteSessionQueue(SessionQueueBase):
) -> SessionQueueItem:
try:
cursor = self._conn.cursor()
cursor.execute(
"""--sql
SELECT status FROM session_queue WHERE item_id = ?
""",
(item_id,),
)
row = cursor.fetchone()
if row is None:
raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}")
current_status = row[0]
# Only update if not already finished (completed, failed or canceled)
if current_status in ("completed", "failed", "canceled"):
return self.get_queue_item(item_id)
cursor.execute(
"""--sql
UPDATE session_queue
@@ -331,6 +341,27 @@ class SqliteSessionQueue(SessionQueueBase):
queue_item = self._set_queue_item_status(item_id=item_id, status="canceled")
return queue_item
def delete_queue_item(self, item_id: int) -> None:
"""Deletes a session queue item"""
try:
self.cancel_queue_item(item_id)
except SessionQueueItemNotFoundError:
pass
try:
cursor = self._conn.cursor()
cursor.execute(
"""--sql
DELETE
FROM session_queue
WHERE item_id = ?
""",
(item_id,),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
def complete_queue_item(self, item_id: int) -> SessionQueueItem:
queue_item = self._set_queue_item_status(item_id=item_id, status="completed")
return queue_item
@@ -428,6 +459,71 @@ class SqliteSessionQueue(SessionQueueBase):
raise
return CancelByDestinationResult(canceled=count)
def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult:
try:
cursor = self._conn.cursor()
current_queue_item = self.get_current(queue_id)
if current_queue_item is not None and current_queue_item.destination == destination:
self.cancel_queue_item(current_queue_item.item_id)
params = (queue_id, destination)
cursor.execute(
"""--sql
SELECT COUNT(*)
FROM session_queue
WHERE
queue_id = ?
AND destination = ?;
""",
params,
)
count = cursor.fetchone()[0]
cursor.execute(
"""--sql
DELETE
FROM session_queue
WHERE
queue_id = ?
AND destination = ?;
""",
params,
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
return DeleteByDestinationResult(deleted=count)
def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult:
try:
cursor = self._conn.cursor()
where = """--sql
WHERE
queue_id == ?
AND status == 'pending'
"""
cursor.execute(
f"""--sql
SELECT COUNT(*)
FROM session_queue
{where};
""",
(queue_id,),
)
count = cursor.fetchone()[0]
cursor.execute(
f"""--sql
DELETE
FROM session_queue
{where};
""",
(queue_id,),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
return DeleteAllExceptCurrentResult(deleted=count)
def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
try:
cursor = self._conn.cursor()
@@ -543,26 +639,12 @@ class SqliteSessionQueue(SessionQueueBase):
priority: int,
cursor: Optional[int] = None,
status: Optional[QUEUE_ITEM_STATUS] = None,
) -> CursorPaginatedResults[SessionQueueItemDTO]:
destination: Optional[str] = None,
) -> CursorPaginatedResults[SessionQueueItem]:
cursor_ = self._conn.cursor()
item_id = cursor
query = """--sql
SELECT item_id,
status,
priority,
field_values,
error_type,
error_message,
error_traceback,
created_at,
updated_at,
completed_at,
started_at,
session_id,
batch_id,
queue_id,
origin,
destination
SELECT *
FROM session_queue
WHERE queue_id = ?
"""
@@ -574,6 +656,12 @@ class SqliteSessionQueue(SessionQueueBase):
"""
params.append(status)
if destination is not None:
query += """---sql
AND destination = ?
"""
params.append(destination)
if item_id is not None:
query += """--sql
AND (priority < ?) OR (priority = ? AND item_id > ?)
@@ -589,7 +677,7 @@ class SqliteSessionQueue(SessionQueueBase):
params.append(limit + 1)
cursor_.execute(query, params)
results = cast(list[sqlite3.Row], cursor_.fetchall())
items = [SessionQueueItemDTO.queue_item_dto_from_dict(dict(result)) for result in results]
items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results]
has_more = False
if len(items) > limit:
# remove the extra item
@@ -597,6 +685,37 @@ class SqliteSessionQueue(SessionQueueBase):
has_more = True
return CursorPaginatedResults(items=items, limit=limit, has_more=has_more)
def list_all_queue_items(
self,
queue_id: str,
destination: Optional[str] = None,
) -> list[SessionQueueItem]:
"""Gets all queue items that match the given parameters"""
cursor_ = self._conn.cursor()
query = """--sql
SELECT *
FROM session_queue
WHERE queue_id = ?
"""
params: list[Union[str, int]] = [queue_id]
if destination is not None:
query += """---sql
AND destination = ?
"""
params.append(destination)
query += """--sql
ORDER BY
priority DESC,
item_id ASC
;
"""
cursor_.execute(query, params)
results = cast(list[sqlite3.Row], cursor_.fetchall())
items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results]
return items
def get_queue_status(self, queue_id: str) -> SessionQueueStatus:
cursor = self._conn.cursor()
cursor.execute(

View File

@@ -7,6 +7,7 @@ from typing import Any, Optional, TypeVar, Union, get_args, get_origin, get_type
import networkx as nx
from pydantic import (
BaseModel,
ConfigDict,
GetCoreSchemaHandler,
GetJsonSchemaHandler,
ValidationError,
@@ -787,6 +788,22 @@ class GraphExecutionState(BaseModel):
default_factory=dict,
)
model_config = ConfigDict(
json_schema_extra={
"required": [
"id",
"graph",
"execution_graph",
"executed",
"executed_history",
"results",
"errors",
"prepared_source_mapping",
"source_prepared_mapping",
]
}
)
@field_validator("graph")
def graph_is_valid(cls, v: Graph):
"""Validates that the graph is valid"""

View File

@@ -42,4 +42,5 @@ IP-Adapters:
- [InvokeAI/ip_adapter_plus_sd15](https://huggingface.co/InvokeAI/ip_adapter_plus_sd15)
- [InvokeAI/ip_adapter_plus_face_sd15](https://huggingface.co/InvokeAI/ip_adapter_plus_face_sd15)
- [InvokeAI/ip_adapter_sdxl](https://huggingface.co/InvokeAI/ip_adapter_sdxl)
- [InvokeAI/ip_adapter_sdxl_vit_h](https://huggingface.co/InvokeAI/ip_adapter_sdxl_vit_h)
- [InvokeAI/ip_adapter_sdxl_vit_h](https://huggingface.co/InvokeAI/ip_adapter_sdxl_vit_h)
- [InvokeAI/ip-adapter-plus_sdxl_vit-h](https://huggingface.co/InvokeAI/ip-adapter-plus_sdxl_vit-h)

View File

@@ -297,6 +297,15 @@ ip_adapter_sdxl = StarterModel(
dependencies=[ip_adapter_sdxl_image_encoder],
previous_names=["IP Adapter SDXL"],
)
ip_adapter_plus_sdxl = StarterModel(
name="Precise Reference (IP Adapter Plus ViT-H)",
base=BaseModelType.StableDiffusionXL,
source="https://huggingface.co/InvokeAI/ip-adapter-plus_sdxl_vit-h/resolve/main/ip-adapter-plus_sdxl_vit-h.safetensors",
description="References images with a higher degree of precision.",
type=ModelType.IPAdapter,
dependencies=[ip_adapter_sdxl_image_encoder],
previous_names=["IP Adapter Plus SDXL"],
)
ip_adapter_flux = StarterModel(
name="Standard Reference (XLabs FLUX IP-Adapter v2)",
base=BaseModelType.Flux,
@@ -672,6 +681,7 @@ STARTER_MODELS: list[StarterModel] = [
ip_adapter_plus_sd1,
ip_adapter_plus_face_sd1,
ip_adapter_sdxl,
ip_adapter_plus_sdxl,
ip_adapter_flux,
qr_code_cnet_sd1,
qr_code_cnet_sdxl,
@@ -744,6 +754,7 @@ sdxl_bundle: list[StarterModel] = [
juggernaut_sdxl,
sdxl_fp16_vae_fix,
ip_adapter_sdxl,
ip_adapter_plus_sdxl,
canny_sdxl,
depth_sdxl,
softedge_sdxl,

View File

@@ -9,7 +9,8 @@ module.exports = {
// https://github.com/qdanik/eslint-plugin-path
'path/no-relative-imports': ['error', { maxDepth: 0 }],
// https://github.com/edvardchen/eslint-plugin-i18next/blob/HEAD/docs/rules/no-literal-string.md
'i18next/no-literal-string': 'error',
// TODO: ENABLE THIS RULE BEFORE v6.0.0
// 'i18next/no-literal-string': 'error',
// https://eslint.org/docs/latest/rules/no-console
'no-console': 'error',
// https://eslint.org/docs/latest/rules/no-promise-executor-return

View File

@@ -3,6 +3,8 @@ import type { KnipConfig } from 'knip';
const config: KnipConfig = {
project: ['src/**/*.{ts,tsx}!'],
ignore: [
// TODO(psyche): temporarily ignored all files for test build purposes
'src/**',
// This file is only used during debugging
'src/app/store/middleware/debugLoggerMiddleware.ts',
// Autogenerated types - shouldn't ever touch these

View File

@@ -60,7 +60,7 @@
"@fontsource-variable/inter": "^5.2.5",
"@invoke-ai/ui-library": "^0.0.46",
"@nanostores/react": "^1.0.0",
"@reduxjs/toolkit": "2.7.0",
"@reduxjs/toolkit": "2.8.2",
"@roarr/browser-log-writer": "^1.3.0",
"@xyflow/react": "^12.6.0",
"async-mutex": "^0.5.0",

View File

@@ -30,8 +30,8 @@ dependencies:
specifier: ^1.0.0
version: 1.0.0(nanostores@1.0.1)(react@18.3.1)
'@reduxjs/toolkit':
specifier: 2.7.0
version: 2.7.0(react-redux@9.2.0)(react@18.3.1)
specifier: 2.8.2
version: 2.8.2(react-redux@9.2.0)(react@18.3.1)
'@roarr/browser-log-writer':
specifier: ^1.3.0
version: 1.3.0
@@ -2161,8 +2161,8 @@ packages:
- supports-color
dev: true
/@reduxjs/toolkit@2.7.0(react-redux@9.2.0)(react@18.3.1):
resolution: {integrity: sha512-XVwolG6eTqwV0N8z/oDlN93ITCIGIop6leXlGJI/4EKy+0POYkR+ABHRSdGXY+0MQvJBP8yAzh+EYFxTuvmBiQ==}
/@reduxjs/toolkit@2.8.2(react-redux@9.2.0)(react@18.3.1):
resolution: {integrity: sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0

View File

@@ -2015,7 +2015,9 @@
"resetGenerationSettings": "Reset Generation Settings",
"replaceCurrent": "Replace Current",
"controlLayerEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, <PullBboxButton>pull the bounding box into this layer</PullBboxButton>, or draw on the canvas to get started.",
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, or <PullBboxButton>pull the bounding box into this layer</PullBboxButton> to get started.",
"referenceImageEmptyStateWithCanvasOptions": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this Reference Image or <PullBboxButton>pull the bounding box into this Reference Image</PullBboxButton> to get started.",
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton> or drag an image from the <GalleryButton>gallery</GalleryButton> onto this Reference Image to get started.",
"uploadOrDragAnImage": "Drag an image from the gallery or <UploadButton>upload an image</UploadButton>.",
"imageNoise": "Image Noise",
"denoiseLimit": "Denoise Limit",
"warnings": {

View File

@@ -2452,9 +2452,8 @@
"watchRecentReleaseVideos": "Guarda i video su questa versione",
"watchUiUpdatesOverview": "Guarda le novità dell'interfaccia",
"items": [
"GPU Nvidia 50xx: Invoke utilizza PyTorch 2.7.0, necessario per queste GPU.",
"Relazioni tra modelli: collega i LoRA ai modelli principali e i LoRA verranno visualizzati per primi nell'elenco.",
"Adattatore IP: nuovi metodi Style (Strong) e Style (Precise) per i modelli SDXL e SD1.5."
"Inpainting: livelli di rumore per maschera e limiti di denoise.",
"Canvas: proporzioni più intelligenti per SDXL e scorrimento e zoom migliorati."
]
},
"system": {

View File

@@ -392,7 +392,7 @@
"title": "全選択"
},
"addNode": {
"desc": "ノード追加メニューを開く",
"desc": "ノード追加メニューを開く",
"title": "ノードを追加"
},
"pasteSelectionWithEdges": {
@@ -1156,11 +1156,11 @@
"unknownField": "不明なフィールド",
"unexpectedField_withName": "予期しないフィールド\"{{name}}\"",
"loadingTemplates": "読み込み中 {{name}}",
"validateConnectionsHelp": "無効な接続が行われたり,無効なグラフが呼び出されたりしないようにします.",
"validateConnectionsHelp": "無効な接続が行われたり,無効なグラフが呼び出されたりしないようにします",
"validateConnections": "接続とグラフを確認する",
"saveToGallery": "ギャラリーに保存",
"newWorkflowDesc": "新しいワークフローを作りますか?",
"unknownFieldType": "$t(nodes.unknownField)型:{type}}",
"unknownFieldType": "$t(nodes.unknownField)型: {{type}}",
"unsupportedArrayItemType": "サポートされていない配列項目型です \"{{type}}\"",
"unableToLoadWorkflow": "ワークフローが読み込めません",
"unableToValidateWorkflow": "ワークフローを確認できません",
@@ -1203,13 +1203,13 @@
"downloadBoard": "ボードをダウンロード",
"changeBoard": "ボードを変更",
"loading": "ロード中...",
"topMessage": "このボードには、以下の機能で使用されている画像が含まれています",
"bottomMessage": "このボードおよび画像を削除すると、現在これらを利用している機能はリセットされます。",
"topMessage": "この選択には、の機能で使用される画像が含まれています:",
"bottomMessage": "この画像を削除すると、現在利用している機能はリセットされます。",
"clearSearch": "検索をクリア",
"deleteBoard": "ボードの削除",
"deleteBoardAndImages": "ボードと画像の削除",
"deleteBoardOnly": "ボードのみ削除",
"deletedBoardsCannotbeRestored": "削除されたボードは復元できません。\"ボードのみ削除\"を選択すると画像は未分類に移動されます。",
"deletedBoardsCannotbeRestored": "削除たボードと画像は復元できません。ボードのみ削除を選択すると画像は未分類の状態になります。",
"movingImagesToBoard_other": "{{count}} の画像をボードに移動:",
"hideBoards": "ボードを隠す",
"assetsWithCount_other": "{{count}} のアセット",
@@ -1224,9 +1224,12 @@
"imagesWithCount_other": "{{count}} の画像",
"updateBoardError": "ボード更新エラー",
"selectedForAutoAdd": "自動追加に選択済み",
"deletedPrivateBoardsCannotbeRestored": "削除されたボードは復元できません。\"ボードのみ削除\"を選択すると画像はその作成者のプライベートな未分類に移動されます。",
"deletedPrivateBoardsCannotbeRestored": "削除されたボードと画像は復元できません。ボードのみ削除を選択すると画像は作成者に対して非公開の未分類状態になります。",
"noBoards": "{{boardType}} ボードがありません",
"viewBoards": "ボードを表示"
"viewBoards": "ボードを表示",
"uncategorizedImages": "分類されていない画像",
"deleteAllUncategorizedImages": "分類されていないすべての画像を削除",
"deletedImagesCannotBeRestored": "削除した画像は復元できません."
},
"invocationCache": {
"invocationCache": "呼び出しキャッシュ",
@@ -1292,25 +1295,49 @@
]
},
"paramUpscaleMethod": {
"heading": "アップスケール手法"
"heading": "アップスケール手法",
"paragraphs": [
"高解像度修正のために画像を拡大するために使用される方法。"
]
},
"upscaleModel": {
"heading": "アップスケールモデル"
"heading": "アップスケールモデル",
"paragraphs": [
"アップスケールモデルは、ディテールを追加する前に画像を出力サイズに合わせて拡大縮小します。サポートされているアップスケールモデルであればどれでも使用できますが、写真や線画など、特定の種類の画像に特化したモデルもあります。"
]
},
"paramAspect": {
"heading": "縦横比"
"heading": "縦横比",
"paragraphs": [
"生成される画像のアスペクト比。比率を変更すると、幅と高さもそれに応じて更新されます。",
"「最適化」は、選択したモデルの幅と高さを最適な寸法に設定します。"
]
},
"refinerSteps": {
"heading": "ステップ"
"heading": "ステップ",
"paragraphs": [
"生成プロセスのリファイナー部分で実行されるステップの数。",
"生成ステップと似ています。"
]
},
"paramVAE": {
"heading": "VAE"
"heading": "VAE",
"paragraphs": [
"AI 出力を最終画像に変換するために使用されるモデル。"
]
},
"scale": {
"heading": "スケール"
"heading": "スケール",
"paragraphs": [
"スケールは出力画像のサイズを制御し、入力画像の解像度の倍数に基づいて決定されます。例えば、1024x1024の画像を2倍に拡大すると、2048x2048の出力が生成されます。"
]
},
"refinerScheduler": {
"heading": "スケジューラー"
"heading": "スケジューラー",
"paragraphs": [
"生成プロセスのリファイナー部分で使用されるスケジューラ。",
"生成スケジューラに似ています。"
]
},
"compositingCoherenceMode": {
"heading": "モード",
@@ -1319,10 +1346,16 @@
]
},
"paramModel": {
"heading": "モデル"
"heading": "モデル",
"paragraphs": [
"生成に使用されるモデル。異なるモデルは、異なる美的結果とコンテンツを生成するように特化するようにトレーニングされています。"
]
},
"paramHeight": {
"heading": "高さ"
"heading": "高さ",
"paragraphs": [
"生成される画像の高さ。8の倍数にする必要があります。"
]
},
"paramSteps": {
"heading": "ステップ",
@@ -1345,7 +1378,11 @@
]
},
"paramIterations": {
"heading": "生成回数"
"heading": "生成回数",
"paragraphs": [
"生成する画像の数。",
"動的プロンプトが有効になっている場合、各プロンプトはこの回数生成されます。"
]
},
"controlNet": {
"heading": "ControlNet",
@@ -1354,7 +1391,10 @@
]
},
"paramWidth": {
"heading": "幅"
"heading": "幅",
"paragraphs": [
"生成される画像の幅。8の倍数にする必要があります。"
]
},
"lora": {
"heading": "LoRA",
@@ -1369,7 +1409,11 @@
]
},
"patchmatchDownScaleSize": {
"heading": "Downscale"
"heading": "Downscale",
"paragraphs": [
"埋め込む前にどの程度のダウンスケーリングが行われるか。",
"ダウンスケーリングを大きくするとパフォーマンスは向上しますが、品質は低下します。"
]
},
"controlNetWeight": {
"heading": "重み",
@@ -1511,6 +1555,124 @@
"paragraphs": [
"アウトペインティングまたはインペインティングのプロセス中に埋め込む方法."
]
},
"paramGuidance": {
"paragraphs": [
"プロンプトが生成プロセスにどの程度影響するかを制御します。",
"ガイダンス値が高すぎると過飽和状態になる可能性があり、ガイダンス値が高すぎるか低すぎると生成結果に歪みが生じる可能性があります。ガイダンスはFLUX DEVモデルにのみ適用されます。"
],
"heading": "ガイダンス"
},
"paramDenoisingStrength": {
"paragraphs": [
"生成されたイメージがラスター レイヤーとどの程度異なるかを制御します。",
"強度が低いほど、結合された表示ラスターレイヤーに近くなります。強度が高いほど、グローバルプロンプトに大きく依存します。",
"表示されるコンテンツを持つラスター レイヤーがない場合、この設定は無視されます。"
],
"heading": "ディノイジングストレングス"
},
"refinerStart": {
"heading": "リファイナースタート",
"paragraphs": [
"生成プロセスのどの時点でリファイナーが使用され始めるか。",
"0 はリファイナーが生成プロセス全体で使用されることを意味し、0.8 は、リファイナーが生成プロセスの最後の 20% で使用されることを意味します。"
]
},
"optimizedDenoising": {
"heading": "イメージtoイメージの最適化",
"paragraphs": [
"「イメージtoイメージを最適化」を有効にすると、Fluxモデルを用いた画像間変換およびインペインティング変換において、より段階的なイズ除去強度スケールが適用されます。この設定により、画像に適用される変化量を制御する能力が向上しますが、標準のイズ除去強度スケールを使用したい場合はオフにすることができます。この設定は現在調整中で、ベータ版です。"
]
},
"refinerPositiveAestheticScore": {
"heading": "ポジティブ美的スコア",
"paragraphs": [
"トレーニング データに基づいて、美的スコアの高い画像に類似するように生成を重み付けします。"
]
},
"paramCFGScale": {
"paragraphs": [
"プロンプトが生成プロセスにどの程度影響するかを制御します。",
"CFG スケールの値が高すぎると、飽和しすぎて生成結果が歪む可能性があります。 "
],
"heading": "CFGスケール"
},
"paramVAEPrecision": {
"paragraphs": [
"VAE エンコードおよびデコード時に使用される精度。",
"Fp16/Half 精度は、画像のわずかな変化を犠牲にして、より効率的です。"
],
"heading": "VAE精度"
},
"refinerModel": {
"heading": "リファイナーモデル",
"paragraphs": [
"生成プロセスの精製部分で使用されるモデル。",
"世代モデルに似ています。"
]
},
"refinerCfgScale": {
"heading": "CFGスケール",
"paragraphs": [
"プロンプトが生成プロセスに与える影響を制御する。",
"生成CFG スケールに似ています。"
]
},
"seamlessTilingYAxis": {
"heading": "シームレスタイリングY軸",
"paragraphs": [
"画像を垂直軸に沿ってシームレスに並べます。"
]
},
"scaleBeforeProcessing": {
"heading": "プロセス前のスケール値",
"paragraphs": [
"「自動」は、画像生成プロセスの前に、選択した領域をモデルに最適なサイズに拡大縮小します。",
"「手動」では、画像生成プロセスの前に、選択した領域を拡大縮小する幅と高さを選択できます。"
]
},
"creativity": {
"heading": "クリエイティビティ",
"paragraphs": [
"クリエイティビティは、ディテールを追加する際のモデルに与えられる自由度を制御します。クリエイティビティが低いと元のイメージに近いままになり、クリエイティビティが高いとより多くの変化を加えることができます。プロンプトを使用する場合、クリエイティビティが高いとプロンプトの影響が増します。"
]
},
"paramHrf": {
"heading": "高解像度修正を有効にする",
"paragraphs": [
"モデルに最適な解像度よりも高い解像度で、高品質な画像を生成します。通常、生成された画像内の重複を防ぐために使用されます。"
]
},
"seamlessTilingXAxis": {
"heading": "シームレスタイリングX軸",
"paragraphs": [
"画像を水平軸に沿ってシームレスに並べます。"
]
},
"paramCFGRescaleMultiplier": {
"paragraphs": [
"ゼロ端末 SNR (ztsnr) を使用してトレーニングされたモデルに使用される、CFG ガイダンスのリスケールマルチプライヤー。",
"これらのモデルの場合、推奨値は 0.7 です。"
],
"heading": "CFG リスケールマルチプライヤー"
},
"structure": {
"heading": "ストラクチャ",
"paragraphs": [
"ストラクチャは、出力画像が元のレイアウトにどれだけ忠実に従うかを制御します。低いストラクチャでは大幅な変更が可能ですが、高いストラクチャでは元の構成とレイアウトが厳密に維持されます。"
]
},
"refinerNegativeAestheticScore": {
"paragraphs": [
"トレーニング データに基づいて、美観スコアが低い画像に類似するように生成に重み付けします。"
],
"heading": "ネガティブ美的スコア"
},
"fluxDevLicense": {
"heading": "非商用ライセンス",
"paragraphs": [
"FLUX.1 [dev]モデルは、FLUX [dev]非商用ライセンスに基づいてライセンスされています。Invokeでこのモデルタイプを商用目的で使用する場合は、当社のウェブサイトをご覧ください。"
]
}
},
"accordions": {
@@ -1683,7 +1845,106 @@
"workflows": "ワークフロー",
"ascending": "昇順",
"name": "名前",
"descending": "降順"
"descending": "降順",
"searchPlaceholder": "名前、説明、タグで検索",
"projectWorkflows": "プロジェクトワークフロー",
"searchWorkflows": "ワークフローを検索",
"updated": "アップデート",
"published": "公表",
"builder": {
"label": "ラベル",
"containerPlaceholder": "空のコンテナ",
"showDescription": "説明を表示",
"emptyRootPlaceholderEditMode": "開始するには、フォーム要素またはノード フィールドをここにドラッグします。",
"divider": "仕切り",
"deleteAllElements": "すべてのフォーム要素を削除",
"heading": "見出し",
"nodeField": "ノードフィールド",
"zoomToNode": "ノードにズーム",
"dropdown": "ドロップダウン",
"resetOptions": "オプションをリセット",
"both": "両方",
"builder": "フォームビルダー",
"text": "テキスト",
"row": "行",
"multiLine": "マルチライン",
"resetAllNodeFields": "すべてのノードフィールドをリセット",
"slider": "スライダー",
"layout": "レイアウト",
"addToForm": "フォームに追加",
"headingPlaceholder": "空の見出し",
"nodeFieldTooltip": "ノード フィールドを追加するには、ワークフロー エディターのフィールドにある小さなプラス記号ボタンをクリックするか、フィールド名をフォームにドラッグします。",
"workflowBuilderAlphaWarning": "ワークフロービルダーは現在アルファ版です。安定版リリースまでに互換性に影響する変更が発生する可能性があります。",
"component": "コンポーネント",
"textPlaceholder": "空のテキスト",
"emptyRootPlaceholderViewMode": "このワークフローのフォームの作成を開始するには、[編集] をクリックします。",
"addOption": "オプションを追加",
"singleLine": "単線",
"numberInput": "数値入力",
"column": "列",
"container": "コンテナ",
"containerRowLayout": "コンテナ(行レイアウト)",
"containerColumnLayout": "コンテナ(列レイアウト)",
"maximum": "最大",
"published": "公開済み",
"publishedWorkflowOutputs": "アウトプット",
"minimum": "最小",
"publish": "公開",
"unpublish": "非公開",
"publishedWorkflowInputs": "インプット"
},
"chooseWorkflowFromLibrary": "ライブラリからワークフローを選択",
"unnamedWorkflow": "名前のないワークフロー",
"download": "ダウンロード",
"savingWorkflow": "ワークフローを保存しています...",
"problemSavingWorkflow": "ワークフローの保存に関する問題",
"convertGraph": "グラフを変換",
"downloadWorkflow": "ファイルに保存",
"saveWorkflow": "ワークフローを保存",
"userWorkflows": "ユーザーワークフロー",
"yourWorkflows": "あなたのワークフロー",
"edit": "編集",
"workflowLibrary": "ワークフローライブラリ",
"workflowSaved": "ワークフローが保存されました",
"clearWorkflowSearchFilter": "ワークフロー検索フィルタをクリア",
"workflowCleared": "ワークフローが作成されました",
"autoLayout": "オートレイアウト",
"view": "ビュー",
"saveChanges": "変更を保存",
"noDescription": "説明なし",
"recommended": "あなたへのおすすめ",
"noRecentWorkflows": "最近のワークフローがありません",
"problemLoading": "ワークフローのローディングに関する問題",
"newWorkflowCreated": "新しいワークフローが作成されました",
"noWorkflows": "ワークフローがありません",
"copyShareLink": "共有リンクをコピー",
"copyShareLinkForWorkflow": "ワークフローの共有リンクをコピー",
"workflowThumbnail": "ワークフローサムネイル",
"loadWorkflow": "$t(common.load) ワークフロー",
"shared": "共有",
"openWorkflow": "ワークフローを開く",
"emptyStringPlaceholder": "<空の文字列>",
"browseWorkflows": "ワークフローを閲覧する",
"saveWorkflowAs": "ワークフローとして保存",
"private": "プライベート",
"deselectAll": "すべて選択解除",
"delete": "削除",
"openLibrary": "ライブラリを開く",
"loadMore": "もっと読み込む",
"saveWorkflowToProject": "ワークフローをプロジェクトに保存",
"created": "作成されました",
"workflowEditorMenu": "ワークフローエディターメニュー",
"defaultWorkflows": "デフォルトワークフロー",
"allLoaded": "すべてのワークフローが読み込まれました",
"filterByTags": "タグでフィルター",
"recentlyOpened": "最近開いた",
"opened": "オープン",
"deleteWorkflow": "ワークフローを削除",
"deleteWorkflow2": "このワークフローを削除してもよろしいですか? 元に戻すことはできません。",
"loadFromGraph": "グラフからワークフローをロード",
"workflowName": "ワークフロー名",
"loading": "ワークフローをロードしています",
"uploadWorkflow": "ファイルからロードする"
},
"system": {
"logNamespaces": {

View File

@@ -2440,8 +2440,7 @@
"watchUiUpdatesOverview": "Xem Tổng Quan Về Những Cập Nhật Cho Giao Diện Người Dùng",
"items": [
"Nvidia 50xx GPUs: Invoke sử dụng PyTorch 2.7.0, thứ tối quan trọng cho những GPU trên.",
"Mối Quan Hệ Model: Kết nối LoRA với model chính, và LoRA đó sẽ được hiển thị đầu danh sách.",
"IP Adapter: Thủ thuật Phong Cách (Mạnh Mẽ) và Phong Cách (Chính Xác) mới cho model SDXL và SD1.5."
"Mối Quan Hệ Model: Kết nối LoRA với model chính, và LoRA đó sẽ được hiển thị đầu danh sách."
]
},
"upsell": {

View File

@@ -8,19 +8,25 @@ import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/ap
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { PartialAppConfig } from 'app/types/invokeai';
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';
import { size } from 'lodash-es';
import { memo, useEffect } from 'react';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import { useGetQueueCountsByDestinationQuery } from 'services/api/endpoints/queue';
import { useSocketIO } from 'services/events/useSocketIO';
const queueCountArg = { destination: 'canvas' };
/**
* GlobalHookIsolator is a logical component that runs global hooks in an isolated component, so that they do not
* cause needless re-renders of any other components.
@@ -38,6 +44,11 @@ export const GlobalHookIsolator = memo(
useGlobalHotkeys();
useGetOpenAPISchemaQuery();
useSyncLoggingConfig();
useCloseChakraTooltipsOnDragFix();
// Persistent subscription to the queue counts query - canvas relies on this to know if there are pending
// and/or in progress canvas sessions.
useGetQueueCountsByDestinationQuery(queueCountArg);
useEffect(() => {
i18n.changeLanguage(language);
@@ -61,6 +72,12 @@ export const GlobalHookIsolator = memo(
useWorkflowBuilderWatcher();
useDynamicPromptsWatcher();
useRegisteredHotkeys({
id: 'toggleViewer',
category: 'viewer',
callback: toggleImageViewer,
});
return null;
}
);

View File

@@ -6,15 +6,17 @@ import {
NewGallerySessionDialog,
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
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';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { DeleteAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog';
import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog';
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
@@ -39,6 +41,7 @@ export const GlobalModalIsolator = memo(() => {
<StylePresetModal />
<WorkflowLibraryModal />
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
<DeleteAllExceptCurrentQueueItemConfirmationAlertDialog />
<ClearQueueConfirmationsAlertDialog />
<NewWorkflowConfirmationAlertDialog />
<LoadWorkflowConfirmationAlertDialog />
@@ -58,6 +61,7 @@ export const GlobalModalIsolator = memo(() => {
<CanvasPasteModal />
</CanvasManagerProviderGate>
<LoadWorkflowFromGraphModal />
<ImageViewerModal />
</>
);
});

View File

@@ -3,8 +3,8 @@ import { useAppStore } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { withResultAsync } from 'common/util/result';
import { canvasReset } from 'features/controlLayers/store/actions';
import { settingsSendToCanvasChanged } from 'features/controlLayers/store/canvasSettingsSlice';
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';
@@ -93,8 +93,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
};
store.dispatch(canvasReset());
store.dispatch(rasterLayerAdded({ overrides, isSelected: true }));
store.dispatch(settingsSendToCanvasChanged(true));
store.dispatch(setActiveTab('canvas'));
store.dispatch(sentImageToCanvas());
$imageViewer.set(false);
toast({
@@ -118,9 +116,9 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
return;
}
const metadata = getImageMetadataResult.value;
store.dispatch(canvasReset());
// This shows a toast
await parseAndRecallAllMetadata(metadata, true);
store.dispatch(setActiveTab('canvas'));
},
[store, t]
);
@@ -164,15 +162,13 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
switch (destination) {
case 'generation':
// Go to the canvas tab, open the image viewer, and enable send-to-gallery mode
store.dispatch(setActiveTab('canvas'));
store.dispatch(paramsReset());
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
store.dispatch(settingsSendToCanvasChanged(false));
$imageViewer.set(true);
break;
case 'canvas':
// Go to the canvas tab, close the image viewer, and disable send-to-gallery mode
store.dispatch(setActiveTab('canvas'));
store.dispatch(settingsSendToCanvasChanged(true));
store.dispatch(canvasReset());
$imageViewer.set(false);
break;
case 'workflows':

View File

@@ -1,7 +1,6 @@
import type { TypedStartListening } from '@reduxjs/toolkit';
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
import { addStagingListeners } from 'app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener';
import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued';
import { addAppConfigReceivedListener } from 'app/store/middleware/listenerMiddleware/listeners/appConfigReceived';
import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
@@ -10,15 +9,14 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
import { addEnsureImageIsSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged';
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
import { addImageDeletionListeners } from 'app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners';
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 { addImageToDeleteSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected';
import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded';
import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
@@ -47,9 +45,7 @@ export const addAppListener = addListener.withTypes<RootState, AppDispatch>();
addImageUploadedFulfilledListener(startAppListening);
// Image deleted
addImageDeletionListeners(startAppListening);
addDeleteBoardAndImagesFulfilledListener(startAppListening);
addImageToDeleteSelectedListener(startAppListening);
// Image starred
addImagesStarredListener(startAppListening);
@@ -65,9 +61,6 @@ addEnqueueRequestedUpscale(startAppListening);
addAnyEnqueuedListener(startAppListening);
addBatchEnqueuedListener(startAppListening);
// Canvas actions
addStagingListeners(startAppListening);
// Socket.IO
addSocketConnectedEventListener(startAppListening);
@@ -95,3 +88,5 @@ addAppConfigReceivedListener(startAppListening);
addAdHocPostProcessingRequestedListener(startAppListening);
addSetDefaultSettingsListener(startAppListening);
addEnsureImageIsSelectedListener(startAppListening);

View File

@@ -1,46 +0,0 @@
import { isAnyOf } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { canvasReset, newSessionRequested } from 'features/controlLayers/store/actions';
import { stagingAreaReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { queueApi } from 'services/api/endpoints/queue';
const log = logger('canvas');
const matchCanvasOrStagingAreaReset = isAnyOf(stagingAreaReset, canvasReset, newSessionRequested);
export const addStagingListeners = (startAppListening: AppStartListening) => {
startAppListening({
matcher: matchCanvasOrStagingAreaReset,
effect: async (_, { dispatch }) => {
try {
const req = dispatch(
queueApi.endpoints.cancelByBatchDestination.initiate(
{ destination: 'canvas' },
{ fixedCacheKey: 'cancelByBatchOrigin' }
)
);
const { canceled } = await req.unwrap();
req.reset();
if (canceled > 0) {
log.debug(`Canceled ${canceled} canvas batches`);
toast({
id: 'CANCEL_BATCH_SUCCEEDED',
title: t('queue.cancelBatchSucceeded'),
status: 'success',
});
}
} catch {
log.error('Failed to cancel canvas batches');
toast({
id: 'CANCEL_BATCH_FAILED',
title: t('queue.cancelBatchFailed'),
status: 'error',
});
}
},
});
};

View File

@@ -1,6 +1,7 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { getImageUsage } from 'features/deleteImageModal/store/selectors';
import { getImageUsage } from 'features/deleteImageModal/store/state';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';
@@ -20,9 +21,10 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
const nodes = selectNodesSlice(state);
const canvas = selectCanvasSlice(state);
const upscale = selectUpscaleSlice(state);
const refImages = selectRefImagesSlice(state);
deleted_images.forEach((image_name) => {
const imageUsage = getImageUsage(nodes, canvas, upscale, image_name);
const imageUsage = getImageUsage(nodes, canvas, upscale, refImages, image_name);
if (imageUsage.isNodesImage && !wasNodeEditorReset) {
dispatch(nodeEditorReset());

View File

@@ -5,6 +5,12 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError';
import { withResult, withResultAsync } from 'common/util/result';
import { parseify } from 'common/util/serialize';
import {
canvasSessionIdCreated,
generateSessionIdCreated,
selectCanvasSessionId,
selectGenerateSessionId,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { $canvasManager } from 'features/controlLayers/store/ephemeral';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph';
@@ -17,6 +23,7 @@ import { buildSD3Graph } from 'features/nodes/util/graph/generation/buildSD3Grap
import { buildSDXLGraph } from 'features/nodes/util/graph/generation/buildSDXLGraph';
import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { serializeError } from 'serialize-error';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
import { assert, AssertionError } from 'tsafe';
@@ -30,11 +37,34 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
actionCreator: enqueueRequestedCanvas,
effect: async (action, { getState, dispatch }) => {
log.debug('Enqueue requested');
const tab = selectActiveTab(getState());
let sessionId = null;
if (tab === 'generate') {
sessionId = selectGenerateSessionId(getState());
if (!sessionId) {
dispatch(generateSessionIdCreated());
sessionId = selectGenerateSessionId(getState());
}
} else if (tab === 'canvas') {
sessionId = selectCanvasSessionId(getState());
if (!sessionId) {
dispatch(canvasSessionIdCreated());
sessionId = selectCanvasSessionId(getState());
}
} else {
log.warn(`Enqueue requested in unsupported tab ${tab}`);
return;
}
const state = getState();
const destination = sessionId;
assert(destination !== null);
const { prepend } = action.payload;
const manager = $canvasManager.get();
assert(manager, 'No canvas manager');
// assert(manager, 'No canvas manager');
const model = state.params.model;
assert(model, 'No model found in state');
@@ -87,8 +117,6 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
const { g, seedFieldIdentifier, positivePromptFieldIdentifier } = buildGraphResult.value;
const destination = state.canvasSettings.sendToCanvas ? 'canvas' : 'gallery';
const prepareBatchResult = withResult(() =>
prepareLinearUIBatch({
state,

View File

@@ -0,0 +1,16 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
export const addEnsureImageIsSelectedListener = (startAppListening: AppStartListening) => {
// When we list images, if no images is selected, select the first one.
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const selection = getState().gallery.selection;
if (selection.length === 0) {
dispatch(imageSelected(action.payload.items[0] ?? null));
}
},
});
};

View File

@@ -1,221 +0,0 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { AppDispatch, RootState } from 'app/store/store';
import { entityDeleted, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { forEach, intersectionBy } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
const log = logger('gallery');
//TODO(psyche): handle image deletion (canvas staging area?)
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
const actions: Param0<typeof dispatch>[] = [];
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
return;
}
forEach(node.data.inputs, (input) => {
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
actions.push(
fieldImageValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: undefined,
})
);
return;
}
if (isImageFieldCollectionInputInstance(input)) {
actions.push(
fieldImageCollectionValueChanged({
nodeId: node.data.id,
fieldName: input.name,
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
})
);
}
});
});
actions.forEach(dispatch);
};
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' && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}
}
if (shouldDelete) {
dispatch(entityDeleted({ entityIdentifier: { id, type: 'control_layer' } }));
}
});
};
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
selectCanvasSlice(state).referenceImages.entities.forEach((entity) => {
if (entity.ipAdapter.image?.image_name === imageDTO.image_name) {
dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: getEntityIdentifier(entity), imageDTO: null }));
}
});
};
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' && obj.image.image_name === imageDTO.image_name) {
shouldDelete = true;
break;
}
}
if (shouldDelete) {
dispatch(entityDeleted({ entityIdentifier: { id, type: 'raster_layer' } }));
}
});
};
export const addImageDeletionListeners = (startAppListening: AppStartListening) => {
// Handle single image deletion
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState }) => {
const { imageDTOs, imagesUsage } = action.payload;
if (imageDTOs.length !== 1 || imagesUsage.length !== 1) {
// handle multiples in separate listener
return;
}
const imageDTO = imageDTOs[0];
const imageUsage = imagesUsage[0];
if (!imageDTO || !imageUsage) {
// satisfy noUncheckedIndexedAccess
return;
}
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO)).unwrap();
if (state.gallery.selection.some((i) => i.image_name === imageDTO.image_name)) {
// The deleted image was a selected image, we need to select the next image
const newSelection = state.gallery.selection.filter((i) => i.image_name !== imageDTO.image_name);
if (newSelection.length > 0) {
return;
}
// Get the current list of images and select the same index
const baseQueryArgs = selectListImagesQueryArgs(state);
const data = imagesApi.endpoints.listImages.select(baseQueryArgs)(state).data;
if (data) {
const deletedImageIndex = data.items.findIndex((i) => i.image_name === imageDTO.image_name);
const nextImage = data.items[deletedImageIndex + 1] ?? data.items[0] ?? null;
if (nextImage?.image_name === imageDTO.image_name) {
// If the next image is the same as the deleted one, it means it was the last image, reset selection
dispatch(imageSelected(null));
} else {
dispatch(imageSelected(nextImage));
}
}
}
deleteNodesImages(state, dispatch, imageDTO);
deleteReferenceImages(state, dispatch, imageDTO);
deleteRasterLayerImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
} catch {
// no-op
} finally {
dispatch(isModalOpenChanged(false));
}
},
});
// Handle multiple image deletion
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState }) => {
const { imageDTOs, imagesUsage } = action.payload;
if (imageDTOs.length <= 1 || imagesUsage.length <= 1) {
// handle singles in separate listener
return;
}
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
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);
if (data) {
// When we delete multiple images, we clear the selection. Then, the the next time we load images, we will
// select the first one. This is handled below in the listener for `imagesApi.endpoints.listImages.matchFulfilled`.
dispatch(imageSelected(null));
}
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
imageDTOs.forEach((imageDTO) => {
deleteNodesImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
deleteReferenceImages(state, dispatch, imageDTO);
deleteRasterLayerImages(state, dispatch, imageDTO);
});
} catch {
// no-op
} finally {
dispatch(isModalOpenChanged(false));
}
},
});
// When we list images, if no images is selected, select the first one.
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const selection = getState().gallery.selection;
if (selection.length === 0) {
dispatch(imageSelected(action.payload.items[0] ?? null));
}
},
});
startAppListening({
matcher: imagesApi.endpoints.deleteImage.matchFulfilled,
effect: (action) => {
log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Image deleted');
},
});
startAppListening({
matcher: imagesApi.endpoints.deleteImage.matchRejected,
effect: (action) => {
log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Unable to delete image');
},
});
};

View File

@@ -1,32 +0,0 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { selectImageUsage } from 'features/deleteImageModal/store/selectors';
import { imagesToDeleteSelected, isModalOpenChanged } from 'features/deleteImageModal/store/slice';
export const addImageToDeleteSelectedListener = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: imagesToDeleteSelected,
effect: (action, { dispatch, getState }) => {
const imageDTOs = action.payload;
const state = getState();
const { shouldConfirmOnDelete } = state.system;
const imagesUsage = selectImageUsage(getState());
const isImageInUse =
imagesUsage.some((i) => i.isRasterLayerImage) ||
imagesUsage.some((i) => i.isControlLayerImage) ||
imagesUsage.some((i) => i.isReferenceImage) ||
imagesUsage.some((i) => i.isInpaintMaskImage) ||
imagesUsage.some((i) => i.isUpscaleImage) ||
imagesUsage.some((i) => i.isNodesImage) ||
imagesUsage.some((i) => i.isRegionalGuidanceImage);
if (shouldConfirmOnDelete || isImageInUse) {
dispatch(isModalOpenChanged(true));
return;
}
dispatch(imageDeletionConfirmed({ imageDTOs, imagesUsage }));
},
});
};

View File

@@ -1,11 +1,7 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { AppDispatch, RootState } from 'app/store/store';
import {
controlLayerModelChanged,
referenceImageIPAdapterModelChanged,
rgIPAdapterModelChanged,
} from 'features/controlLayers/store/canvasSlice';
import { controlLayerModelChanged, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
import {
clipEmbedModelSelected,
@@ -15,8 +11,9 @@ import {
t5EncoderModelSelected,
vaeSelected,
} from 'features/controlLayers/store/paramsSlice';
import { refImageModelChanged, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { getEntityIdentifier, isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types';
import { modelSelected } from 'features/parameters/store/actions';
import { postProcessingModelChanged, upscaleModelChanged } from 'features/parameters/store/upscaleSlice';
import {
@@ -210,12 +207,12 @@ const handleControlAdapterModels: ModelHandler = (models, state, dispatch, log)
const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
const ipaModels = models.filter(isIPAdapterModelConfig);
selectCanvasSlice(state).referenceImages.entities.forEach((entity) => {
if (entity.ipAdapter.type !== 'ip_adapter') {
selectRefImagesSlice(state).entities.forEach((entity) => {
if (!isIPAdapterConfig(entity.config)) {
return;
}
const selectedIPAdapterModel = entity.ipAdapter.model;
const selectedIPAdapterModel = entity.config.model;
// `null` is a valid IP adapter model - no need to do anything.
if (!selectedIPAdapterModel) {
return;
@@ -225,16 +222,16 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
return;
}
log.debug({ selectedIPAdapterModel }, 'Selected IP adapter model is not available, clearing');
dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), modelConfig: null }));
dispatch(refImageModelChanged({ id: entity.id, modelConfig: null }));
});
selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => {
entity.referenceImages.forEach(({ id: referenceImageId, ipAdapter }) => {
if (ipAdapter.type !== 'ip_adapter') {
entity.referenceImages.forEach(({ id: referenceImageId, config }) => {
if (!isIPAdapterConfig(config)) {
return;
}
const selectedIPAdapterModel = ipAdapter.model;
const selectedIPAdapterModel = config.model;
// `null` is a valid IP adapter model - no need to do anything.
if (!selectedIPAdapterModel) {
return;
@@ -245,7 +242,7 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
}
log.debug({ selectedIPAdapterModel }, 'Selected IP adapter model is not available, clearing');
dispatch(
rgIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null })
rgRefImageModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null })
);
});
});
@@ -254,11 +251,11 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => {
const fluxReduxModels = models.filter(isFluxReduxModelConfig);
selectCanvasSlice(state).referenceImages.entities.forEach((entity) => {
if (entity.ipAdapter.type !== 'flux_redux') {
selectRefImagesSlice(state).entities.forEach((entity) => {
if (!isFLUXReduxConfig(entity.config)) {
return;
}
const selectedFLUXReduxModel = entity.ipAdapter.model;
const selectedFLUXReduxModel = entity.config.model;
// `null` is a valid FLUX Redux model - no need to do anything.
if (!selectedFLUXReduxModel) {
return;
@@ -268,16 +265,16 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => {
return;
}
log.debug({ selectedFLUXReduxModel }, 'Selected FLUX Redux model is not available, clearing');
dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), modelConfig: null }));
dispatch(refImageModelChanged({ id: entity.id, modelConfig: null }));
});
selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => {
entity.referenceImages.forEach(({ id: referenceImageId, ipAdapter }) => {
if (ipAdapter.type !== 'flux_redux') {
entity.referenceImages.forEach(({ id: referenceImageId, config }) => {
if (!isFLUXReduxConfig(config)) {
return;
}
const selectedFLUXReduxModel = ipAdapter.model;
const selectedFLUXReduxModel = config.model;
// `null` is a valid FLUX Redux model - no need to do anything.
if (!selectedFLUXReduxModel) {
return;
@@ -288,7 +285,7 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => {
}
log.debug({ selectedFLUXReduxModel }, 'Selected FLUX Redux model is not available, clearing');
dispatch(
rgIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null })
rgRefImageModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null })
);
});
});

View File

@@ -8,12 +8,12 @@ import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import { canvasPersistConfig, canvasSlice, canvasUndoableConfig } from 'features/controlLayers/store/canvasSlice';
import {
canvasSessionSlice,
canvasStagingAreaPersistConfig,
canvasStagingAreaSlice,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice';
import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice';
import { deleteImageModalSlice } from 'features/deleteImageModal/store/slice';
import { refImagesPersistConfig, refImagesSlice } from 'features/controlLayers/store/refImagesSlice';
import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice';
import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice';
@@ -54,7 +54,6 @@ const allReducers = {
[configSlice.name]: configSlice.reducer,
[uiSlice.name]: uiSlice.reducer,
[dynamicPromptsSlice.name]: dynamicPromptsSlice.reducer,
[deleteImageModalSlice.name]: deleteImageModalSlice.reducer,
[changeBoardModalSlice.name]: changeBoardModalSlice.reducer,
[modelManagerV2Slice.name]: modelManagerV2Slice.reducer,
[queueSlice.name]: queueSlice.reducer,
@@ -65,9 +64,10 @@ const allReducers = {
[stylePresetSlice.name]: stylePresetSlice.reducer,
[paramsSlice.name]: paramsSlice.reducer,
[canvasSettingsSlice.name]: canvasSettingsSlice.reducer,
[canvasStagingAreaSlice.name]: canvasStagingAreaSlice.reducer,
[canvasSessionSlice.name]: canvasSessionSlice.reducer,
[lorasSlice.name]: lorasSlice.reducer,
[workflowLibrarySlice.name]: workflowLibrarySlice.reducer,
[refImagesSlice.name]: refImagesSlice.reducer,
};
const rootReducer = combineReducers(allReducers);
@@ -113,6 +113,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[canvasStagingAreaPersistConfig.name]: canvasStagingAreaPersistConfig,
[lorasPersistConfig.name]: lorasPersistConfig,
[workflowLibraryPersistConfig.name]: workflowLibraryPersistConfig,
[refImagesSlice.name]: refImagesPersistConfig,
};
const unserialize: UnserializeFunction = (data, key) => {
@@ -175,6 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
.concat(api.middleware)
.concat(dynamicMiddlewares)
.concat(authToastMiddleware)
// .concat(getDebugLoggerMiddleware())
.prepend(listenerMiddleware.middleware),
enhancers: (getDefaultEnhancers) => {
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());
@@ -209,3 +211,4 @@ export type RootState = ReturnType<AppStore['getState']>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AppThunkDispatch = ThunkDispatch<RootState, any, UnknownAction>;
export type AppDispatch = ReturnType<typeof createStore>['dispatch'];
export type AppGetState = ReturnType<typeof createStore>['getState'];

View File

@@ -11,13 +11,14 @@ import { memo, useEffect, useMemo, useState } from 'react';
type Props = PropsWithChildren & {
maxHeight?: ChakraProps['maxHeight'];
maxWidth?: ChakraProps['maxWidth'];
overflowX?: 'hidden' | 'scroll';
overflowY?: 'hidden' | 'scroll';
};
const styles: CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };
const ScrollableContent = ({ children, maxHeight, overflowX = 'hidden', overflowY = 'scroll' }: Props) => {
const ScrollableContent = ({ children, maxHeight, maxWidth, overflowX = 'hidden', overflowY = 'scroll' }: Props) => {
const overlayscrollbarsOptions = useMemo(
() => getOverlayScrollbarsParams({ overflowX, overflowY }).options,
[overflowX, overflowY]
@@ -44,7 +45,7 @@ const ScrollableContent = ({ children, maxHeight, overflowX = 'hidden', overflow
}, [os]);
return (
<Flex w="full" h="full" maxHeight={maxHeight} position="relative">
<Flex w="full" h="full" maxHeight={maxHeight} maxWidth={maxWidth} position="relative">
<Box position="absolute" top={0} left={0} right={0} bottom={0}>
<OverlayScrollbarsComponent ref={osRef} style={styles} options={overlayscrollbarsOptions}>
{children}

View File

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

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
// Chakra tooltips sometimes open during a drag operation. We can fix it by dispatching an event that chakra listens
// for to close tooltips. It's reaching into the internals but it seems to work.
const closeEventName = 'chakra-ui:close-tooltip';
export const useCloseChakraTooltipsOnDragFix = () => {
useEffect(() => {
const closeTooltips = () => {
document.dispatchEvent(new window.CustomEvent(closeEventName));
};
document.addEventListener('drag', closeTooltips);
return () => {
document.removeEventListener('drag', closeTooltips);
};
}, []);
};

View File

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

View File

@@ -1,6 +1,6 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem';
import { useInvoke } from 'features/queue/hooks/useInvoke';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -35,34 +35,30 @@ export const useGlobalHotkeys = () => {
dependencies: [queue],
});
const {
cancelQueueItem,
isDisabled: isDisabledCancelQueueItem,
isLoading: isLoadingCancelQueueItem,
} = useCancelCurrentQueueItem();
const deleteCurrentQueueItem = useDeleteCurrentQueueItem();
useRegisteredHotkeys({
id: 'cancelQueueItem',
category: 'app',
callback: cancelQueueItem,
callback: deleteCurrentQueueItem.trigger,
options: {
enabled: !isDisabledCancelQueueItem && !isLoadingCancelQueueItem,
enabled: !deleteCurrentQueueItem.isDisabled && !deleteCurrentQueueItem.isLoading,
preventDefault: true,
},
dependencies: [cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem],
dependencies: [deleteCurrentQueueItem],
});
const { clearQueue, isDisabled: isDisabledClearQueue, isLoading: isLoadingClearQueue } = useClearQueue();
const clearQueue = useClearQueue();
useRegisteredHotkeys({
id: 'clearQueue',
category: 'app',
callback: clearQueue,
callback: clearQueue.trigger,
options: {
enabled: !isDisabledClearQueue && !isLoadingClearQueue,
enabled: !clearQueue.isDisabled && !clearQueue.isLoading,
preventDefault: true,
},
dependencies: [clearQueue, isDisabledClearQueue, isLoadingClearQueue],
dependencies: [clearQueue],
});
useRegisteredHotkeys({

View File

@@ -1,11 +1,11 @@
import type { IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import type { ButtonProps, IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Button, IconButton } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { useAppSelector } from 'app/store/storeHooks';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { memo, useCallback } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
@@ -163,32 +163,63 @@ const sx = {
},
} satisfies SystemStyleObject;
export const UploadImageButton = ({
isDisabled = false,
onUpload,
isError = false,
...rest
}: {
export const UploadImageIconButton = memo(
({
isDisabled = false,
onUpload,
isError = false,
...rest
}: {
onUpload?: (imageDTO: ImageDTO) => void;
isError?: boolean;
} & SetOptional<IconButtonProps, 'aria-label'>) => {
const uploadApi = useImageUploadButton({ isDisabled, allowMultiple: false, onUpload });
return (
<>
<IconButton
aria-label="Upload image"
variant="outline"
sx={sx}
data-error={isError}
icon={<PiUploadBold />}
isLoading={uploadApi.request.isLoading}
{...rest}
{...uploadApi.getUploadButtonProps()}
/>
<input {...uploadApi.getUploadInputProps()} />
</>
);
}
);
UploadImageIconButton.displayName = 'UploadImageIconButton';
type UploadImageButtonProps = {
onUpload?: (imageDTO: ImageDTO) => void;
isError?: boolean;
} & SetOptional<IconButtonProps, 'aria-label'>) => {
} & ButtonProps;
const UploadImageButton = memo((props: UploadImageButtonProps) => {
const { children, isDisabled = false, onUpload, isError = false, ...rest } = props;
const uploadApi = useImageUploadButton({ isDisabled, allowMultiple: false, onUpload });
return (
<>
<IconButton
<Button
aria-label="Upload image"
variant="outline"
sx={sx}
data-error={isError}
icon={<PiUploadBold />}
rightIcon={<PiUploadBold />}
isLoading={uploadApi.request.isLoading}
{...rest}
{...uploadApi.getUploadButtonProps()}
/>
>
{children ?? 'Upload'}
</Button>
<input {...uploadApi.getUploadInputProps()} />
</>
);
};
});
UploadImageButton.displayName = 'UploadImageButton';
export const UploadMultipleImageButton = ({
isDisabled = false,

View File

@@ -1,12 +1,18 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import type { GroupBase } from 'chakra-react-select';
import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import type { ModelIdentifierField } from 'features/nodes/types/common';
import { uniq } from 'lodash-es';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships';
import type { AnyModelConfig } from 'services/api/types';
import { useGroupedModelCombobox } from './useGroupedModelCombobox';
import { useRelatedModelKeys } from './useRelatedModelKeys';
import { useSelectedModelKeys } from './useSelectedModelKeys';
type UseRelatedGroupedModelComboboxArg<T extends AnyModelConfig> = {
modelConfigs: T[];
@@ -29,6 +35,32 @@ type UseRelatedGroupedModelComboboxReturn = {
noOptionsMessage: () => string;
};
const selectSelectedModelKeys = createMemoizedSelector(selectParamsSlice, selectLoRAsSlice, (params, loras) => {
const keys: string[] = [];
const main = params.model;
const vae = params.vae;
const refiner = params.refinerModel;
const controlnet = params.controlLora;
if (main) {
keys.push(main.key);
}
if (vae) {
keys.push(vae.key);
}
if (refiner) {
keys.push(refiner.key);
}
if (controlnet) {
keys.push(controlnet.key);
}
for (const { model } of loras.loras) {
keys.push(model.key);
}
return uniq(keys);
});
export function useRelatedGroupedModelCombobox<T extends AnyModelConfig>({
modelConfigs,
selectedModel,
@@ -39,9 +71,15 @@ export function useRelatedGroupedModelCombobox<T extends AnyModelConfig>({
}: UseRelatedGroupedModelComboboxArg<T>): UseRelatedGroupedModelComboboxReturn {
const { t } = useTranslation();
const selectedKeys = useSelectedModelKeys();
const relatedKeys = useRelatedModelKeys(selectedKeys);
const selectedKeys = useAppSelector(selectSelectedModelKeys);
const { relatedKeys } = useGetRelatedModelIdsBatchQuery(selectedKeys, {
selectFromResult: ({ data }) => {
if (!data) {
return { relatedKeys: EMPTY_ARRAY };
}
return { relatedKeys: data };
},
});
// Base grouped options
const base = useGroupedModelCombobox({
@@ -53,40 +91,42 @@ export function useRelatedGroupedModelCombobox<T extends AnyModelConfig>({
groupByType,
});
// If no related models selected, just return base
if (relatedKeys.size === 0) {
return base;
}
const options = useMemo(() => {
if (relatedKeys.length === 0) {
return base.options;
}
const relatedOptions: ComboboxOption[] = [];
const updatedGroups: GroupBase<ComboboxOption>[] = [];
const relatedOptions: ComboboxOption[] = [];
const updatedGroups: GroupBase<ComboboxOption>[] = [];
for (const group of base.options) {
const remainingOptions: ComboboxOption[] = [];
for (const group of base.options) {
const remainingOptions: ComboboxOption[] = [];
for (const option of group.options) {
if (relatedKeys.has(option.value)) {
relatedOptions.push({ ...option, label: `* ${option.label}` });
} else {
remainingOptions.push(option);
for (const option of group.options) {
if (relatedKeys.includes(option.value)) {
relatedOptions.push({ ...option, label: `* ${option.label}` });
} else {
remainingOptions.push(option);
}
}
if (remainingOptions.length > 0) {
updatedGroups.push({
label: group.label,
options: remainingOptions,
});
}
}
if (remainingOptions.length > 0) {
updatedGroups.push({
label: group.label,
options: remainingOptions,
});
if (relatedOptions.length > 0) {
return [{ label: t('modelManager.relatedModels'), options: relatedOptions }, ...updatedGroups];
} else {
return updatedGroups;
}
}
const finalOptions: GroupBase<ComboboxOption>[] =
relatedOptions.length > 0
? [{ label: t('modelManager.relatedModels'), options: relatedOptions }, ...updatedGroups]
: updatedGroups;
}, [base.options, relatedKeys, t]);
return {
...base,
options: finalOptions,
options,
};
}

View File

@@ -1,14 +0,0 @@
import { useMemo } from 'react';
import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships';
/**
* Fetches related model keys for a given set of selected model keys.
* Returns a Set<string> for fast lookup.
*/
export const useRelatedModelKeys = (selectedKeys: Set<string>) => {
const { data: related = [] } = useGetRelatedModelIdsBatchQuery([...selectedKeys], {
skip: selectedKeys.size === 0,
});
return useMemo(() => new Set(related), [related]);
};

View File

@@ -1,34 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
/**
* Gathers all currently selected model keys from parameters and loras.
* This includes the main model, VAE, refiner model, controlnet, and loras.
*/
export const useSelectedModelKeys = () => {
return useAppSelector((state) => {
const keys = new Set<string>();
const main = state.params.model;
const vae = state.params.vae;
const refiner = state.params.refinerModel;
const controlnet = state.params.controlLora;
const loras = state.loras.loras.map((l) => l.model);
if (main) {
keys.add(main.key);
}
if (vae) {
keys.add(vae.key);
}
if (refiner) {
keys.add(refiner.key);
}
if (controlnet) {
keys.add(controlnet.key);
}
for (const lora of loras) {
keys.add(lora.key);
}
return keys;
});
};

View File

@@ -0,0 +1,10 @@
import type { z } from 'zod';
/**
* Helper to create a type guard from a zod schema. The type guard will infer the schema's TS type.
* @param schema The zod schema to create a type guard from.
* @returns A type guard function for the schema.
*/
export const buildZodTypeGuard = <T extends z.ZodTypeAny>(schema: T) => {
return (val: unknown): val is z.infer<T> => schema.safeParse(val).success;
};

View File

@@ -1,17 +1,10 @@
import {
ContextMenu,
Flex,
IconButton,
Menu,
MenuButton,
MenuList,
type SystemStyleObject,
} from '@invoke-ai/ui-library';
import type { SystemStyleObject } 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';
import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask';
import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus';
import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo';
import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems';
import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems';
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
@@ -19,24 +12,22 @@ import { Filter } from 'features/controlLayers/components/Filters/Filter';
import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context';
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 { GatedImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { memo, useCallback } from 'react';
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress';
const FOCUS_REGION_STYLES: SystemStyleObject = {
width: 'full',
height: 'full',
};
const MenuContent = () => {
const MenuContent = memo(() => {
return (
<CanvasManagerProviderGate>
<MenuList>
@@ -45,9 +36,22 @@ const MenuContent = () => {
</MenuList>
</CanvasManagerProviderGate>
);
});
MenuContent.displayName = 'MenuContent';
const canvasBgSx = {
position: 'relative',
w: 'full',
h: 'full',
borderRadius: 'base',
overflow: 'hidden',
bg: 'base.900',
'&[data-dynamic-grid="true"]': {
bg: 'base.850',
},
};
export const CanvasMainPanelContent = memo(() => {
export const AdvancedSession = memo(({ id }: { id: string | null }) => {
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);
@@ -72,17 +76,10 @@ export const CanvasMainPanelContent = memo(() => {
<CanvasManagerProviderGate>
<CanvasToolbar />
</CanvasManagerProviderGate>
<Divider />
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} withLongPress={false}>
{(ref) => (
<Flex
ref={ref}
position="relative"
w="full"
h="full"
bg={dynamicGrid ? 'base.850' : 'base.900'}
borderRadius="base"
overflow="hidden"
>
<Flex ref={ref} sx={canvasBgSx} data-dynamic-grid={dynamicGrid}>
<InvokeCanvasComponent />
<CanvasManagerProviderGate>
<Flex
@@ -97,7 +94,6 @@ export const CanvasMainPanelContent = memo(() => {
{showHUD && <CanvasHUD />}
<CanvasAlertsSelectedEntityStatus />
<CanvasAlertsPreserveMask />
<CanvasAlertsSendingToGallery />
<CanvasAlertsInvocationProgress />
</Flex>
<Flex position="absolute" top={1} insetInlineEnd={1}>
@@ -110,13 +106,29 @@ export const CanvasMainPanelContent = memo(() => {
</Flex>
)}
</ContextMenu>
<Flex position="absolute" bottom={4} gap={2} align="center" justify="center">
{id !== null && (
<CanvasManagerProviderGate>
<StagingAreaIsStagingGate>
<StagingAreaToolbar />
</StagingAreaIsStagingGate>
<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>
)}
<Flex position="absolute" bottom={4}>
<CanvasManagerProviderGate>
<Filter />
@@ -127,10 +139,8 @@ export const CanvasMainPanelContent = memo(() => {
<CanvasManagerProviderGate>
<CanvasDropArea />
</CanvasManagerProviderGate>
<GatedImageViewer />
</Flex>
</FocusRegionWrapper>
);
});
CanvasMainPanelContent.displayName = 'CanvasMainPanelContent';
AdvancedSession.displayName = 'AdvancedSession';

View File

@@ -2,11 +2,10 @@ import { Button, Flex, Heading } from '@invoke-ai/ui-library';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import {
useAddControlLayer,
useAddGlobalReferenceImage,
useAddInpaintMask,
useAddNewRegionalGuidanceWithARefImage,
useAddRasterLayer,
useAddRegionalGuidance,
useAddRegionalReferenceImage,
} from 'features/controlLayers/hooks/addLayerHooks';
import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled';
import { memo } from 'react';
@@ -19,9 +18,7 @@ export const CanvasAddEntityButtons = memo(() => {
const addRegionalGuidance = useAddRegionalGuidance();
const addRasterLayer = useAddRasterLayer();
const addControlLayer = useAddControlLayer();
const addGlobalReferenceImage = useAddGlobalReferenceImage();
const addRegionalReferenceImage = useAddRegionalReferenceImage();
const isReferenceImageEnabled = useIsEntityTypeEnabled('reference_image');
const addRegionalReferenceImage = useAddNewRegionalGuidanceWithARefImage();
const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance');
const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer');
const isInpaintLayerEnabled = useIsEntityTypeEnabled('inpaint_mask');
@@ -29,21 +26,6 @@ export const CanvasAddEntityButtons = memo(() => {
return (
<Flex w="full" h="full" justifyContent="center" gap={4}>
<Flex position="relative" flexDir="column" gap={4} top="20%">
<Flex flexDir="column" justifyContent="flex-start" gap={2}>
<Heading size="xs">{t('controlLayers.global')}</Heading>
<InformationalPopover feature="globalReferenceImage">
<Button
size="sm"
variant="ghost"
justifyContent="flex-start"
leftIcon={<PiPlusBold />}
onClick={addGlobalReferenceImage}
isDisabled={!isReferenceImageEnabled}
>
{t('controlLayers.globalReferenceImage')}
</Button>
</InformationalPopover>
</Flex>
<Flex flexDir="column" gap={2}>
<Heading size="xs">{t('controlLayers.regional')}</Heading>
<InformationalPopover feature="inpainting">

View File

@@ -6,11 +6,11 @@ import { selectIsLocal } from 'features/system/store/configSlice';
import { selectSystemShouldShowInvocationProgressDetail } from 'features/system/store/systemSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { $invocationProgressMessage } from 'services/events/stores';
import { $lastProgressMessage } from 'services/events/stores';
const CanvasAlertsInvocationProgressContentLocal = memo(() => {
const { t } = useTranslation();
const invocationProgressMessage = useStore($invocationProgressMessage);
const invocationProgressMessage = useStore($lastProgressMessage);
if (!invocationProgressMessage) {
return null;

View File

@@ -1,146 +0,0 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, Flex } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useCurrentDestination } from 'features/queue/hooks/useCurrentDestination';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
import { AnimatePresence, motion } from 'framer-motion';
import type { PropsWithChildren, ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
const ActivateImageViewerButton = (props: PropsWithChildren) => {
const imageViewer = useImageViewer();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
imageViewer.open();
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [imageViewer, dispatch]);
return (
<Button onClick={onClick} size="sm" variant="link" color="base.50">
{props.children}
</Button>
);
};
export const CanvasAlertsSendingToGallery = () => {
const { t } = useTranslation();
const destination = useCurrentDestination();
const tab = useAppSelector(selectActiveTab);
const isVisible = useMemo(() => {
// This alert should only be visible when the destination is gallery and the tab is canvas
if (tab !== 'canvas') {
return false;
}
if (!destination) {
return false;
}
return destination === 'gallery';
}, [destination, tab]);
return (
<AlertWrapper
title={t('controlLayers.sendingToGallery')}
description={
<Trans i18nKey="controlLayers.viewProgressInViewer" components={{ Btn: <ActivateImageViewerButton /> }} />
}
isVisible={isVisible}
/>
);
};
const ActivateCanvasButton = (props: PropsWithChildren) => {
const dispatch = useAppDispatch();
const imageViewer = useImageViewer();
const onClick = useCallback(() => {
dispatch(setActiveTab('canvas'));
dispatch(activeTabCanvasRightPanelChanged('layers'));
imageViewer.close();
}, [dispatch, imageViewer]);
return (
<Button onClick={onClick} size="sm" variant="link" color="base.50">
{props.children}
</Button>
);
};
export const CanvasAlertsSendingToCanvas = () => {
const { t } = useTranslation();
const destination = useCurrentDestination();
const isStaging = useAppSelector(selectIsStaging);
const tab = useAppSelector(selectActiveTab);
const isVisible = useMemo(() => {
// When we are on a non-canvas tab, and the current generation's destination is not the canvas, we don't show the alert
// For example, on the workflows tab, when the destinatin is gallery, we don't show the alert
if (tab !== 'canvas' && destination !== 'canvas') {
return false;
}
if (isStaging) {
return true;
}
if (!destination) {
return false;
}
return destination === 'canvas';
}, [destination, isStaging, tab]);
return (
<AlertWrapper
title={t('controlLayers.sendingToCanvas')}
description={
<Trans i18nKey="controlLayers.viewProgressOnCanvas" components={{ Btn: <ActivateCanvasButton /> }} />
}
isVisible={isVisible}
/>
);
};
const AlertWrapper = ({
title,
description,
isVisible,
}: {
title: ReactNode;
description: ReactNode;
isVisible: boolean;
}) => {
const isHovered = useBoolean(false);
return (
<AnimatePresence>
{(isVisible || isHovered.isTrue) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.1, ease: 'easeOut' } }}
exit={{
opacity: 0,
transition: { duration: 0.1, delay: !isHovered.isTrue ? 1 : 0.1, ease: 'easeIn' },
}}
onMouseEnter={isHovered.setTrue}
onMouseLeave={isHovered.setFalse}
>
<Alert
status="warning"
flexDir="column"
pointerEvents="auto"
borderRadius="base"
fontSize="sm"
shadow="md"
w="fit-content"
>
<Flex w="full" alignItems="center">
<AlertIcon />
<AlertTitle>{title}</AlertTitle>
</Flex>
<AlertDescription>{description}</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,28 @@
import { Spinner } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useAllEntityAdapters } from 'features/controlLayers/contexts/EntityAdapterContext';
import { computed } from 'nanostores';
import { memo, useMemo } from 'react';
export const CanvasBusySpinner = memo(() => {
const canvasManager = useCanvasManager();
const allEntityAdapters = useAllEntityAdapters();
const $isPendingRectCalculation = useMemo(
() =>
computed(
allEntityAdapters.map(({ transformer }) => transformer.$isPendingRectCalculation),
(...values) => values.some((v) => v)
),
[allEntityAdapters]
);
const isPendingRectCalculation = useStore($isPendingRectCalculation);
const isRasterizing = useStore(canvasManager.stateApi.$isRasterizing);
const isCompositing = useStore(canvasManager.compositor.$isBusy);
if (isRasterizing || isCompositing || isPendingRectCalculation) {
return <Spinner opacity={0.3} />;
}
return null;
});
CanvasBusySpinner.displayName = 'CanvasBusySpinner';

View File

@@ -2,8 +2,8 @@ import { MenuGroup } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ControlLayerMenuItems } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItems';
import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItems';
import { IPAdapterMenuItems } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItems';
import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems';
import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems';
import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import {

View File

@@ -2,7 +2,6 @@ import { Grid, GridItem } from '@invoke-ai/ui-library';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { newCanvasEntityFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,19 +12,11 @@ const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.
const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
type: 'regional_guidance_with_reference_image',
});
const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
type: 'reference_image',
});
export const CanvasDropArea = memo(() => {
const { t } = useTranslation();
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusy();
if (imageViewer.isOpen) {
return null;
}
return (
<>
<Grid
@@ -63,14 +54,6 @@ export const CanvasDropArea = memo(() => {
isDisabled={isBusy}
/>
</GridItem>
<GridItem position="relative">
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addGlobalReferenceImageFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newGlobalReferenceImage')}
isDisabled={isBusy}
/>
</GridItem>
</Grid>
</>
);

View File

@@ -14,7 +14,6 @@ import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
import { entitiesReordered } from 'features/controlLayers/store/canvasSlice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { isRenderableEntityType } from 'features/controlLayers/store/types';
import { singleCanvasEntityDndSource } from 'features/dnd/dnd';
import { triggerPostMoveFlash } from 'features/dnd/util';
import type { PropsWithChildren } from 'react';
@@ -165,8 +164,8 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
<Spacer />
</Flex>
{isRenderableEntityType(type) && <CanvasEntityMergeVisibleButton type={type} />}
{isRenderableEntityType(type) && <CanvasEntityTypeIsHiddenToggle type={type} />}
<CanvasEntityMergeVisibleButton type={type} />
<CanvasEntityTypeIsHiddenToggle type={type} />
<CanvasEntityAddOfTypeButton type={type} />
</Flex>
<Collapse in={collapse.isTrue} style={fixTooltipCloseOnScrollStyles}>

View File

@@ -2,7 +2,6 @@ import { Flex } from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { ControlLayerEntityList } from 'features/controlLayers/components/ControlLayer/ControlLayerEntityList';
import { InpaintMaskList } from 'features/controlLayers/components/InpaintMask/InpaintMaskList';
import { IPAdapterList } from 'features/controlLayers/components/IPAdapter/IPAdapterList';
import { RasterLayerEntityList } from 'features/controlLayers/components/RasterLayer/RasterLayerEntityList';
import { RegionalGuidanceEntityList } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList';
import { memo } from 'react';
@@ -11,7 +10,6 @@ export const CanvasEntityList = memo(() => {
return (
<ScrollableContent>
<Flex flexDir="column" gap={2} data-testid="control-layers-layer-list" w="full" h="full">
<IPAdapterList />
<InpaintMaskList />
<RegionalGuidanceEntityList />
<ControlLayerEntityList />

View File

@@ -1,11 +1,10 @@
import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library';
import {
useAddControlLayer,
useAddGlobalReferenceImage,
useAddInpaintMask,
useAddNewRegionalGuidanceWithARefImage,
useAddRasterLayer,
useAddRegionalGuidance,
useAddRegionalReferenceImage,
} from 'features/controlLayers/hooks/addLayerHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled';
@@ -16,13 +15,11 @@ import { PiPlusBold } from 'react-icons/pi';
export const EntityListGlobalActionBarAddLayerMenu = memo(() => {
const { t } = useTranslation();
const isBusy = useCanvasIsBusy();
const addGlobalReferenceImage = useAddGlobalReferenceImage();
const addInpaintMask = useAddInpaintMask();
const addRegionalGuidance = useAddRegionalGuidance();
const addRegionalReferenceImage = useAddRegionalReferenceImage();
const addRegionalReferenceImage = useAddNewRegionalGuidanceWithARefImage();
const addRasterLayer = useAddRasterLayer();
const addControlLayer = useAddControlLayer();
const isReferenceImageEnabled = useIsEntityTypeEnabled('reference_image');
const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance');
const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer');
const isInpaintLayerEnabled = useIsEntityTypeEnabled('inpaint_mask');
@@ -41,11 +38,6 @@ export const EntityListGlobalActionBarAddLayerMenu = memo(() => {
isDisabled={isBusy}
/>
<MenuList>
<MenuGroup title={t('controlLayers.global')}>
<MenuItem icon={<PiPlusBold />} onClick={addGlobalReferenceImage} isDisabled={!isReferenceImageEnabled}>
{t('controlLayers.globalReferenceImage')}
</MenuItem>
</MenuGroup>
<MenuGroup title={t('controlLayers.regional')}>
<MenuItem icon={<PiPlusBold />} onClick={addInpaintMask} isDisabled={!isInpaintLayerEnabled}>
{t('controlLayers.inpaintMask')}

View File

@@ -22,7 +22,6 @@ import {
selectEntity,
selectSelectedEntityIdentifier,
} from 'features/controlLayers/store/selectors';
import { isRenderableEntity } from 'features/controlLayers/store/types';
import { clamp, round } from 'lodash-es';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
@@ -70,9 +69,6 @@ const selectOpacity = createSelector(selectCanvasSlice, (canvas) => {
if (!selectedEntity) {
return 1; // fallback to 100% opacity
}
if (!isRenderableEntity(selectedEntity)) {
return 1; // fallback to 100% opacity
}
// Opacity is a float from 0-1, but we want to display it as a percentage
return selectedEntity.opacity;
});
@@ -134,11 +130,7 @@ export const EntityListSelectedEntityActionBarOpacity = memo(() => {
return (
<Popover>
<FormControl
w="min-content"
gap={2}
isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'reference_image'}
>
<FormControl w="min-content" gap={2} isDisabled={selectedEntityIdentifier === null}>
<FormLabel m={0}>{t('controlLayers.opacity')}</FormLabel>
<PopoverAnchor>
<NumberInput
@@ -167,7 +159,7 @@ export const EntityListSelectedEntityActionBarOpacity = memo(() => {
position="absolute"
insetInlineEnd={0}
h="full"
isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'reference_image'}
isDisabled={selectedEntityIdentifier === null}
/>
</PopoverTrigger>
</NumberInput>
@@ -185,7 +177,7 @@ export const EntityListSelectedEntityActionBarOpacity = memo(() => {
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'reference_image'}
isDisabled={selectedEntityIdentifier === null}
/>
</PopoverBody>
</PopoverContent>

View File

@@ -1,272 +0,0 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
import { Box, Button, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectEntityCountActive } from 'features/controlLayers/store/selectors';
import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import type { DndTargetState } from 'features/dnd/types';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const CanvasRightPanel = memo(() => {
const { t } = useTranslation();
const activeTab = useAppSelector(selectActiveTabCanvasRightPanel);
const imageViewer = useImageViewer();
const dispatch = useAppDispatch();
const tabIndex = useMemo(() => {
if (activeTab === 'gallery') {
return 1;
} else {
return 0;
}
}, [activeTab]);
const onClickViewerToggleButton = useCallback(() => {
if (activeTab !== 'gallery') {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}
imageViewer.toggle();
}, [imageViewer, activeTab, dispatch]);
const onChangeTab = useCallback(
(index: number) => {
if (index === 0) {
dispatch(activeTabCanvasRightPanelChanged('layers'));
} else {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}
},
[dispatch]
);
useRegisteredHotkeys({
id: 'toggleViewer',
category: 'viewer',
callback: imageViewer.toggle,
dependencies: [imageViewer],
});
return (
<Tabs index={tabIndex} onChange={onChangeTab} w="full" h="full" display="flex" flexDir="column">
<TabList alignItems="center">
<PanelTabs />
<Spacer />
<Button size="sm" variant="ghost" onClick={onClickViewerToggleButton}>
{imageViewer.isOpen ? t('gallery.closeViewer') : t('gallery.openViewer')}
</Button>
</TabList>
<TabPanels w="full" h="full">
<TabPanel w="full" h="full" p={0} pt={3}>
<CanvasManagerProviderGate>
<CanvasLayersPanelContent />
</CanvasManagerProviderGate>
</TabPanel>
<TabPanel w="full" h="full" p={0} pt={3}>
<GalleryPanelContent />
</TabPanel>
</TabPanels>
</Tabs>
);
});
CanvasRightPanel.displayName = 'CanvasRightPanel';
const PanelTabs = memo(() => {
const { t } = useTranslation();
const store = useAppStore();
const activeEntityCount = useAppSelector(selectEntityCountActive);
const [layersTabDndState, setLayersTabDndState] = useState<DndTargetState>('idle');
const [galleryTabDndState, setGalleryTabDndState] = useState<DndTargetState>('idle');
const layersTabRef = useRef<HTMLDivElement>(null);
const galleryTabRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<number | null>(null);
const layersTabLabel = useMemo(() => {
if (activeEntityCount === 0) {
return t('controlLayers.layer_other');
}
return `${t('controlLayers.layer_other')} (${activeEntityCount})`;
}, [activeEntityCount, t]);
useEffect(() => {
if (!layersTabRef.current) {
return;
}
const getIsOnLayersTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'layers';
const onDragEnter = () => {
// If we are already on the layers tab, do nothing
if (getIsOnLayersTab()) {
return;
}
// Else set the state to active and switch to the layers tab after a timeout
setLayersTabDndState('over');
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
store.dispatch(activeTabCanvasRightPanelChanged('layers'));
// When we switch tabs, the other tab should be pending
setLayersTabDndState('idle');
setGalleryTabDndState('potential');
}, 300);
};
const onDragLeave = () => {
// Set the state to idle or pending depending on the current tab
if (getIsOnLayersTab()) {
setLayersTabDndState('idle');
} else {
setLayersTabDndState('potential');
}
// Abort the tab switch if it hasn't happened yet
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
const onDragStart = () => {
// Set the state to pending when a drag starts
setLayersTabDndState('potential');
};
return combine(
dropTargetForElements({
element: layersTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForElements({
canMonitor: ({ source }) => {
if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) {
return false;
}
// Only monitor if we are not already on the gallery tab
return !getIsOnLayersTab();
},
onDragStart,
}),
dropTargetForExternal({
element: layersTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForExternal({
canMonitor: () => !getIsOnLayersTab(),
onDragStart,
})
);
}, [store]);
useEffect(() => {
if (!galleryTabRef.current) {
return;
}
const getIsOnGalleryTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'gallery';
const onDragEnter = () => {
// If we are already on the gallery tab, do nothing
if (getIsOnGalleryTab()) {
return;
}
// Else set the state to active and switch to the gallery tab after a timeout
setGalleryTabDndState('over');
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
// When we switch tabs, the other tab should be pending
setGalleryTabDndState('idle');
setLayersTabDndState('potential');
}, 300);
};
const onDragLeave = () => {
// Set the state to idle or pending depending on the current tab
if (getIsOnGalleryTab()) {
setGalleryTabDndState('idle');
} else {
setGalleryTabDndState('potential');
}
// Abort the tab switch if it hasn't happened yet
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
const onDragStart = () => {
// Set the state to pending when a drag starts
setGalleryTabDndState('potential');
};
return combine(
dropTargetForElements({
element: galleryTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForElements({
canMonitor: ({ source }) => {
if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) {
return false;
}
// Only monitor if we are not already on the gallery tab
return !getIsOnGalleryTab();
},
onDragStart,
}),
dropTargetForExternal({
element: galleryTabRef.current,
onDragEnter,
onDragLeave,
}),
monitorForExternal({
canMonitor: () => !getIsOnGalleryTab(),
onDragStart,
})
);
}, [store]);
useEffect(() => {
const onDrop = () => {
// Reset the dnd state when a drop happens
setGalleryTabDndState('idle');
setLayersTabDndState('idle');
};
const cleanup = combine(monitorForElements({ onDrop }), monitorForExternal({ onDrop }));
return () => {
cleanup();
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<>
<Tab ref={layersTabRef} position="relative" w={32}>
<Box as="span" w="full">
{layersTabLabel}
</Box>
<DndDropOverlay dndState={layersTabDndState} withBackdrop={false} />
</Tab>
<Tab ref={galleryTabRef} position="relative" w={32}>
<Box as="span" w="full">
{t('gallery.gallery')}
</Box>
<DndDropOverlay dndState={galleryTabDndState} withBackdrop={false} />
</Tab>
</>
);
});
PanelTabs.displayName = 'PanelTabs';

View File

@@ -1,35 +0,0 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useMemo } from 'react';
type Props = {
id: string;
};
export const IPAdapter = memo(({ id }: Props) => {
const entityIdentifier = useMemo<CanvasEntityIdentifier>(() => ({ id, type: 'reference_image' }), [id]);
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader ps={4} py={5}>
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<IPAdapterSettings />
</CanvasEntityContainer>
</CanvasEntityStateGate>
</EntityIdentifierContext.Provider>
);
});
IPAdapter.displayName = 'IPAdapter';

View File

@@ -1,37 +0,0 @@
/* eslint-disable i18next/no-literal-string */
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.referenceImages.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
return selectedEntityIdentifier?.type === 'reference_image';
});
export const IPAdapterList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
if (entityIdentifiers.length === 0) {
return null;
}
if (entityIdentifiers.length > 0) {
return (
<CanvasEntityGroupList type="reference_image" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
{entityIdentifiers.map((entityIdentifiers) => (
<IPAdapter key={entityIdentifiers.id} id={entityIdentifiers.id} />
))}
</CanvasEntityGroupList>
);
}
});
IPAdapterList.displayName = 'IPAdapterList';

View File

@@ -1,180 +0,0 @@
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper';
import { Weight } from 'features/controlLayers/components/common/Weight';
import { CLIPVisionModel } from 'features/controlLayers/components/IPAdapter/CLIPVisionModel';
import { FLUXReduxImageInfluence } from 'features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence';
import { GlobalReferenceImageModel } from 'features/controlLayers/components/IPAdapter/GlobalReferenceImageModel';
import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod';
import { IPAdapterSettingsEmptyState } from 'features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
referenceImageIPAdapterBeginEndStepPctChanged,
referenceImageIPAdapterCLIPVisionModelChanged,
referenceImageIPAdapterFLUXReduxImageInfluenceChanged,
referenceImageIPAdapterImageChanged,
referenceImageIPAdapterMethodChanged,
referenceImageIPAdapterModelChanged,
referenceImageIPAdapterWeightChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice, selectEntity, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type {
CanvasEntityIdentifier,
CLIPVisionModelV2,
FLUXReduxImageInfluence as FLUXReduxImageInfluenceType,
IPMethodV2,
} from 'features/controlLayers/store/types';
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold } from 'react-icons/pi';
import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { IPAdapterImagePreview } from './IPAdapterImagePreview';
const buildSelectIPAdapter = (entityIdentifier: CanvasEntityIdentifier<'reference_image'>) =>
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'IPAdapterSettings').ipAdapter
);
const IPAdapterSettingsContent = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('reference_image');
const selectIPAdapter = useMemo(() => buildSelectIPAdapter(entityIdentifier), [entityIdentifier]);
const ipAdapter = useAppSelector(selectIPAdapter);
const onChangeBeginEndStepPct = useCallback(
(beginEndStepPct: [number, number]) => {
dispatch(referenceImageIPAdapterBeginEndStepPctChanged({ entityIdentifier, beginEndStepPct }));
},
[dispatch, entityIdentifier]
);
const onChangeWeight = useCallback(
(weight: number) => {
dispatch(referenceImageIPAdapterWeightChanged({ entityIdentifier, weight }));
},
[dispatch, entityIdentifier]
);
const onChangeIPMethod = useCallback(
(method: IPMethodV2) => {
dispatch(referenceImageIPAdapterMethodChanged({ entityIdentifier, method }));
},
[dispatch, entityIdentifier]
);
const onChangeFLUXReduxImageInfluence = useCallback(
(imageInfluence: FLUXReduxImageInfluenceType) => {
dispatch(referenceImageIPAdapterFLUXReduxImageInfluenceChanged({ entityIdentifier, imageInfluence }));
},
[dispatch, entityIdentifier]
);
const onChangeModel = useCallback(
(modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => {
dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier, modelConfig }));
},
[dispatch, entityIdentifier]
);
const onChangeCLIPVisionModel = useCallback(
(clipVisionModel: CLIPVisionModelV2) => {
dispatch(referenceImageIPAdapterCLIPVisionModelChanged({ entityIdentifier, clipVisionModel }));
},
[dispatch, entityIdentifier]
);
const onChangeImage = useCallback(
(imageDTO: ImageDTO | null) => {
dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO }));
},
[dispatch, entityIdentifier]
);
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
() => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }, ipAdapter.image?.image_name),
[entityIdentifier, ipAdapter.image?.image_name]
);
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier);
const isBusy = useCanvasIsBusy();
const isFLUX = useAppSelector(selectIsFLUX);
return (
<CanvasEntitySettingsWrapper>
<Flex flexDir="column" gap={2} position="relative" w="full">
<Flex gap={2} alignItems="center" w="full">
<GlobalReferenceImageModel modelKey={ipAdapter.model?.key ?? null} onChangeModel={onChangeModel} />
{ipAdapter.type === 'ip_adapter' && (
<CLIPVisionModel model={ipAdapter.clipVisionModel} onChange={onChangeCLIPVisionModel} />
)}
<IconButton
onClick={pullBboxIntoIPAdapter}
isDisabled={isBusy}
variant="ghost"
aria-label={t('controlLayers.pullBboxIntoReferenceImage')}
tooltip={t('controlLayers.pullBboxIntoReferenceImage')}
icon={<PiBoundingBoxBold />}
/>
</Flex>
<Flex gap={2} w="full">
{ipAdapter.type === 'ip_adapter' && (
<Flex flexDir="column" gap={2} w="full">
{!isFLUX && <IPAdapterMethod method={ipAdapter.method} onChange={onChangeIPMethod} />}
<Weight weight={ipAdapter.weight} onChange={onChangeWeight} />
<BeginEndStepPct beginEndStepPct={ipAdapter.beginEndStepPct} onChange={onChangeBeginEndStepPct} />
</Flex>
)}
{ipAdapter.type === 'flux_redux' && (
<Flex flexDir="column" gap={2} w="full" alignItems="flex-start">
<FLUXReduxImageInfluence
imageInfluence={ipAdapter.imageInfluence ?? 'lowest'}
onChange={onChangeFLUXReduxImageInfluence}
/>
</Flex>
)}
<Flex alignItems="center" justifyContent="center" h={32} w={32} aspectRatio="1/1" flexGrow={1}>
<IPAdapterImagePreview
image={ipAdapter.image}
onChangeImage={onChangeImage}
dndTarget={setGlobalReferenceImageDndTarget}
dndTargetData={dndTargetData}
/>
</Flex>
</Flex>
</Flex>
</CanvasEntitySettingsWrapper>
);
});
IPAdapterSettingsContent.displayName = 'IPAdapterSettingsContent';
const buildSelectIPAdapterHasImage = (entityIdentifier: CanvasEntityIdentifier<'reference_image'>) =>
createSelector(selectCanvasSlice, (canvas) => {
const referenceImage = selectEntity(canvas, entityIdentifier);
return !!referenceImage && referenceImage.ipAdapter.image !== null;
});
export const IPAdapterSettings = memo(() => {
const entityIdentifier = useEntityIdentifierContext('reference_image');
const selectIPAdapterHasImage = useMemo(() => buildSelectIPAdapterHasImage(entityIdentifier), [entityIdentifier]);
const hasImage = useAppSelector(selectIPAdapterHasImage);
if (!hasImage) {
return <IPAdapterSettingsEmptyState />;
}
return <IPAdapterSettingsContent />;
});
IPAdapterSettings.displayName = 'IPAdapterSettings';

View File

@@ -2,8 +2,7 @@ import { Checkbox, ConfirmationAlertDialog, Flex, FormControl, FormLabel, Text }
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { newCanvasSessionRequested, newGallerySessionRequested } from 'features/controlLayers/store/actions';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
selectSystemShouldConfirmOnNewSession,
shouldConfirmOnNewSessionToggled,
@@ -17,15 +16,13 @@ const [useNewCanvasSessionDialog] = buildUseBoolean(false);
export const useNewGallerySession = () => {
const dispatch = useAppDispatch();
const imageViewer = useImageViewer();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const newSessionDialog = useNewGallerySessionDialog();
const newGallerySessionImmediate = useCallback(() => {
dispatch(newGallerySessionRequested());
imageViewer.open();
dispatch(generateSessionReset());
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch, imageViewer]);
}, [dispatch]);
const newGallerySessionWithDialog = useCallback(() => {
if (shouldConfirmOnNewSession) {
@@ -40,15 +37,13 @@ export const useNewGallerySession = () => {
export const useNewCanvasSession = () => {
const dispatch = useAppDispatch();
const imageViewer = useImageViewer();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const newSessionDialog = useNewCanvasSessionDialog();
const newCanvasSessionImmediate = useCallback(() => {
dispatch(newCanvasSessionRequested());
imageViewer.close();
dispatch(canvasSessionReset());
dispatch(activeTabCanvasRightPanelChanged('layers'));
}, [dispatch, imageViewer]);
}, [dispatch]);
const newCanvasSessionWithDialog = useCallback(() => {
if (shouldConfirmOnNewSession) {

View File

@@ -1,5 +1,5 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { memo } from 'react';
@@ -8,8 +8,8 @@ import { PiBoundingBoxBold } from 'react-icons/pi';
export const IPAdapterMenuItemPullBbox = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext('reference_image');
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier);
const id = useRefImageIdContext();
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id);
const isBusy = useCanvasIsBusy();
return (

View File

@@ -3,7 +3,7 @@ import { IconMenuItemGroup } from 'common/components/IconMenuItem';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { IPAdapterMenuItemPullBbox } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox';
import { IPAdapterMenuItemPullBbox } from 'features/controlLayers/components/RefImage/IPAdapterMenuItemPullBbox';
import { memo } from 'react';
export const IPAdapterMenuItems = memo(() => {

View File

@@ -0,0 +1,132 @@
import {
Divider,
Flex,
IconButton,
Image,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
Portal,
Skeleton,
} from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { POPPER_MODIFIERS } from 'common/components/InformationalPopover/constants';
import type { UseDisclosure } from 'common/hooks/useBoolean';
import { useDisclosure } from 'common/hooks/useBoolean';
import { DEFAULT_FILTER, useFilterableOutsideClick } from 'common/hooks/useFilterableOutsideClick';
import { RefImageHeader } from 'features/controlLayers/components/RefImage/RefImageHeader';
import { RefImageSettings } from 'features/controlLayers/components/RefImage/RefImageSettings';
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
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
// handling of refs, portals, outside clicks, and a race condition with framer-motion animations that can leave
// the popover closed when its internal state is still open.
//
// We have to manually manage the popover open state to work around the race condition, and then have to do special
// handling to close the popover when clicking outside of it.
// We have to reach outside react to identify the popover trigger element instead of using refs, thanks to how Chakra
// handles refs for PopoverAnchor internally. Maybe there is some way to merge them but I couldn't figure it out.
const getRefImagePopoverTriggerId = (id: string) => `ref-image-popover-trigger-${id}`;
export const RefImage = memo(() => {
const id = useRefImageIdContext();
const ref = useRef<HTMLDivElement>(null);
const disclosure = useDisclosure(false);
// This filter prevents the popover from closing when clicking on a sibling portal element, like the dropdown menu
// inside the ref image settings popover. It also prevents the popover from closing when clicking on the popover's
// own trigger element.
const filter = useCallback(
(el: HTMLElement | SVGElement) => {
return DEFAULT_FILTER(el) || el.id === getRefImagePopoverTriggerId(id);
},
[id]
);
useFilterableOutsideClick({ ref, handler: disclosure.close, filter });
return (
<Popover
// The popover contains a react-select component, which uses a portal to render its options. This portal
// is itself not lazy. As a result, if we do not unmount the popover when it is closed, the react-select
// component still exists but is invisible, and intercepts clicks!
isLazy
lazyBehavior="unmount"
isOpen={disclosure.isOpen}
closeOnBlur={false}
modifiers={POPPER_MODIFIERS}
>
<Thumbnail disclosure={disclosure} />
<Portal>
<PopoverContent ref={ref} w={400}>
<PopoverArrow />
<PopoverBody>
<Flex flexDir="column" gap={2} w="full" h="full">
<RefImageHeader />
<Divider />
<RefImageSettings />
</Flex>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
});
RefImage.displayName = 'RefImage';
const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => {
const id = useRefImageIdContext();
const entity = useRefImageEntity(id);
const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken);
if (!entity.config.image) {
return (
<PopoverAnchor>
<IconButton
id={getRefImagePopoverTriggerId(id)}
aria-label="Open Reference Image Settings"
h="full"
variant="ghost"
aspectRatio="1/1"
borderWidth="2px !important"
borderStyle="dashed !important"
borderColor="errorAlpha.500"
borderRadius="base"
icon={<PiImageBold />}
colorScheme="error"
onClick={disclosure.toggle}
flexShrink={0}
/>
</PopoverAnchor>
);
}
return (
<PopoverAnchor>
<Image
borderWidth={1}
borderStyle="solid"
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}
flexShrink={0}
// sx={imageSx}
// data-is-open={disclosure.isOpen}
/>
</PopoverAnchor>
);
});
Thumbnail.displayName = 'Thumbnail';

View File

@@ -0,0 +1,41 @@
import { Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { refImageDeleted } from 'features/controlLayers/store/refImagesSlice';
import { memo, useCallback } from 'react';
import { PiTrashBold } from 'react-icons/pi';
export const RefImageHeader = memo(() => {
const id = useRefImageIdContext();
const dispatch = useAppDispatch();
const entity = useRefImageEntity(id);
const deleteRefImage = useCallback(() => {
dispatch(refImageDeleted({ id }));
}, [dispatch, id]);
return (
<Flex justifyContent="space-between" alignItems="center" w="full">
{entity.config.image !== null && (
<Text fontWeight="semibold" color="base.300">
Reference Image
</Text>
)}
{entity.config.image === null && (
<Text fontWeight="semibold" color="base.300">
No Reference Image Selected
</Text>
)}
<IconButton
size="xs"
variant="link"
alignSelf="stretch"
icon={<PiTrashBold />}
onClick={deleteRefImage}
aria-label="Delete reference image"
colorScheme="error"
/>
</Flex>
);
});
RefImageHeader.displayName = 'RefImageHeader';

View File

@@ -1,7 +1,7 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { UploadImageButton } from 'common/hooks/useImageUploadButton';
import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
import type { ImageWithDims } from 'features/controlLayers/store/types';
import type { setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
@@ -21,7 +21,7 @@ type Props<T extends typeof setGlobalReferenceImageDndTarget | typeof setRegiona
dndTargetData: ReturnType<T['getData']>;
};
export const IPAdapterImagePreview = memo(
export const RefImageImage = memo(
<T extends typeof setGlobalReferenceImageDndTarget | typeof setRegionalGuidanceReferenceImageDndTarget>({
image,
onChangeImage,
@@ -51,7 +51,7 @@ export const IPAdapterImagePreview = memo(
return (
<Flex position="relative" w="full" h="full" alignItems="center" data-error={!imageDTO && !image?.image_name}>
{!imageDTO && (
<UploadImageButton
<UploadImageIconButton
w="full"
h="full"
isError={!imageDTO && !image?.image_name}
@@ -77,4 +77,4 @@ export const IPAdapterImagePreview = memo(
}
);
IPAdapterImagePreview.displayName = 'IPAdapterImagePreview';
RefImageImage.displayName = 'RefImageImage';

View File

@@ -0,0 +1,94 @@
import type { FlexProps } from '@invoke-ai/ui-library';
import { Button, Flex } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { RefImage } from 'features/controlLayers/components/RefImage/RefImage';
import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { refImageAdded, selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { memo, useMemo } from 'react';
import { PiUploadBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
export const RefImageList = memo((props: FlexProps) => {
const ids = useAppSelector(selectRefImageEntityIds);
return (
<Flex gap={2} h={16} {...props}>
{ids.map((id) => (
<RefImageIdContext.Provider key={id} value={id}>
<RefImage />
</RefImageIdContext.Provider>
))}
{ids.length < 5 && <AddRefImageDropTargetAndButton />}
{ids.length >= 5 && <MaxRefImages />}
</Flex>
);
});
RefImageList.displayName = 'RefImageList';
const dndTargetData = addGlobalReferenceImageDndTarget.getData();
const MaxRefImages = memo(() => {
return (
<Button
position="relative"
size="sm"
variant="ghost"
h="full"
w="full"
borderWidth="2px !important"
borderStyle="dashed !important"
borderRadius="base"
isDisabled
>
Max Ref Images
</Button>
);
});
MaxRefImages.displayName = 'MaxRefImages';
const AddRefImageDropTargetAndButton = memo(() => {
const { dispatch, getState } = useAppStore();
const uploadOptions = useMemo(
() =>
({
onUpload: (imageDTO: ImageDTO) => {
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
},
allowMultiple: false,
}) as const,
[dispatch, getState]
);
const uploadApi = useImageUploadButton(uploadOptions);
return (
<>
<Button
position="relative"
size="sm"
variant="ghost"
h="full"
w="full"
borderWidth="2px !important"
borderStyle="dashed !important"
borderRadius="base"
leftIcon={<PiUploadBold />}
{...uploadApi.getUploadButtonProps()}
>
Reference Image
<input {...uploadApi.getUploadInputProps()} />
<DndDropTarget label="Drop" dndTarget={addGlobalReferenceImageDndTarget} dndTargetData={dndTargetData} />
</Button>
</>
);
});
AddRefImageDropTargetAndButton.displayName = 'AddRefImageDropTargetAndButton';

View File

@@ -12,7 +12,7 @@ type Props = {
onChangeModel: (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => void;
};
export const GlobalReferenceImageModel = memo(({ modelKey, onChangeModel }: Props) => {
export const RefImageModel = memo(({ modelKey, onChangeModel }: Props) => {
const { t } = useTranslation();
const currentBaseModel = useAppSelector(selectBase);
const [modelConfigs, { isLoading }] = useGlobalReferenceImageModels();
@@ -60,4 +60,4 @@ export const GlobalReferenceImageModel = memo(({ modelKey, onChangeModel }: Prop
);
});
GlobalReferenceImageModel.displayName = 'GlobalReferenceImageModel';
RefImageModel.displayName = 'RefImageModel';

View File

@@ -0,0 +1,57 @@
import { Button, Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { setGlobalReferenceImage } from 'features/imageActions/actions';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
export const RefImageNoImageState = memo(() => {
const { t } = useTranslation();
const id = useRefImageIdContext();
const dispatch = useAppDispatch();
const onUpload = useCallback(
(imageDTO: ImageDTO) => {
setGlobalReferenceImage({ imageDTO, id, dispatch });
},
[dispatch, id]
);
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
const onClickGalleryButton = useCallback(() => {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
() => setGlobalReferenceImageDndTarget.getData({ id }),
[id]
);
const components = useMemo(
() => ({
UploadButton: <Button size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />,
GalleryButton: <Button onClick={onClickGalleryButton} size="sm" variant="link" color="base.300" />,
}),
[onClickGalleryButton, uploadApi]
);
return (
<Flex flexDir="column" gap={3} position="relative" w="full" p={4}>
<Text textAlign="center" color="base.300">
<Trans i18nKey="controlLayers.referenceImageEmptyState" components={components} />
</Text>
<input {...uploadApi.getUploadInputProps()} />
<DndDropTarget
dndTarget={setGlobalReferenceImageDndTarget}
dndTargetData={dndTargetData}
label={t('controlLayers.useImage')}
/>
</Flex>
);
});
RefImageNoImageState.displayName = 'RefImageNoImageState';

View File

@@ -1,7 +1,7 @@
import { Button, Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
@@ -13,26 +13,26 @@ import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
export const IPAdapterSettingsEmptyState = memo(() => {
export const RefImageNoImageStateWithCanvasOptions = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext('reference_image');
const id = useRefImageIdContext();
const dispatch = useAppDispatch();
const isBusy = useCanvasIsBusy();
const onUpload = useCallback(
(imageDTO: ImageDTO) => {
setGlobalReferenceImage({ imageDTO, entityIdentifier, dispatch });
setGlobalReferenceImage({ imageDTO, id, dispatch });
},
[dispatch, entityIdentifier]
[dispatch, id]
);
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
const onClickGalleryButton = useCallback(() => {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier);
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id);
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
() => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }),
[entityIdentifier]
() => setGlobalReferenceImageDndTarget.getData({ id }),
[id]
);
const components = useMemo(
@@ -53,7 +53,7 @@ export const IPAdapterSettingsEmptyState = memo(() => {
return (
<Flex flexDir="column" gap={3} position="relative" w="full" p={4}>
<Text textAlign="center" color="base.300">
<Trans i18nKey="controlLayers.referenceImageEmptyState" components={components} />
<Trans i18nKey="controlLayers.referenceImageEmptyStateWithCanvasOptions" components={components} />
</Text>
<input {...uploadApi.getUploadInputProps()} />
<DndDropTarget
@@ -66,4 +66,4 @@ export const IPAdapterSettingsEmptyState = memo(() => {
);
});
IPAdapterSettingsEmptyState.displayName = 'IPAdapterSettingsEmptyState';
RefImageNoImageStateWithCanvasOptions.displayName = 'RefImageNoImageStateWithCanvasOptions';

View File

@@ -0,0 +1,186 @@
import { Flex } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence';
import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel';
import { PullBboxIntoRefImageIconButton } from 'features/controlLayers/components/common/PullBboxIntoRefImageIconButton';
import { Weight } from 'features/controlLayers/components/common/Weight';
import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod';
import { RefImageModel } from 'features/controlLayers/components/RefImage/RefImageModel';
import { RefImageNoImageState } from 'features/controlLayers/components/RefImage/RefImageNoImageState';
import { RefImageNoImageStateWithCanvasOptions } from 'features/controlLayers/components/RefImage/RefImageNoImageStateWithCanvasOptions';
import {
CanvasManagerProviderGate,
useCanvasManagerSafe,
} from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import {
refImageFLUXReduxImageInfluenceChanged,
refImageImageChanged,
refImageIPAdapterBeginEndStepPctChanged,
refImageIPAdapterCLIPVisionModelChanged,
refImageIPAdapterMethodChanged,
refImageIPAdapterWeightChanged,
refImageModelChanged,
selectRefImageEntity,
selectRefImageEntityOrThrow,
selectRefImagesSlice,
} from 'features/controlLayers/store/refImagesSlice';
import type {
CLIPVisionModelV2,
FLUXReduxImageInfluence as FLUXReduxImageInfluenceType,
IPMethodV2,
} from 'features/controlLayers/store/types';
import { isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types';
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useMemo } from 'react';
import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { RefImageImage } from './RefImageImage';
const buildSelectConfig = (id: string) =>
createSelector(
selectRefImagesSlice,
(refImages) => selectRefImageEntityOrThrow(refImages, id, 'IPAdapterSettings').config
);
const RefImageSettingsContent = memo(() => {
const dispatch = useAppDispatch();
const id = useRefImageIdContext();
const selectConfig = useMemo(() => buildSelectConfig(id), [id]);
const config = useAppSelector(selectConfig);
const tab = useAppSelector(selectActiveTab);
const onChangeBeginEndStepPct = useCallback(
(beginEndStepPct: [number, number]) => {
dispatch(refImageIPAdapterBeginEndStepPctChanged({ id, beginEndStepPct }));
},
[dispatch, id]
);
const onChangeWeight = useCallback(
(weight: number) => {
dispatch(refImageIPAdapterWeightChanged({ id, weight }));
},
[dispatch, id]
);
const onChangeIPMethod = useCallback(
(method: IPMethodV2) => {
dispatch(refImageIPAdapterMethodChanged({ id, method }));
},
[dispatch, id]
);
const onChangeFLUXReduxImageInfluence = useCallback(
(imageInfluence: FLUXReduxImageInfluenceType) => {
dispatch(refImageFLUXReduxImageInfluenceChanged({ id, imageInfluence }));
},
[dispatch, id]
);
const onChangeModel = useCallback(
(modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => {
dispatch(refImageModelChanged({ id, modelConfig }));
},
[dispatch, id]
);
const onChangeCLIPVisionModel = useCallback(
(clipVisionModel: CLIPVisionModelV2) => {
dispatch(refImageIPAdapterCLIPVisionModelChanged({ id, clipVisionModel }));
},
[dispatch, id]
);
const onChangeImage = useCallback(
(imageDTO: ImageDTO | null) => {
dispatch(refImageImageChanged({ id, imageDTO }));
},
[dispatch, id]
);
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
() => setGlobalReferenceImageDndTarget.getData({ id }, config.image?.image_name),
[id, config.image?.image_name]
);
const isFLUX = useAppSelector(selectIsFLUX);
return (
<Flex flexDir="column" gap={2} position="relative" w="full">
<Flex gap={2} alignItems="center" w="full">
<RefImageModel modelKey={config.model?.key ?? null} onChangeModel={onChangeModel} />
{isIPAdapterConfig(config) && (
<IPAdapterCLIPVisionModel model={config.clipVisionModel} onChange={onChangeCLIPVisionModel} />
)}
{tab === 'canvas' && (
<CanvasManagerProviderGate>
<PullBboxIntoRefImageIconButton />
</CanvasManagerProviderGate>
)}
</Flex>
<Flex gap={2} w="full">
{isIPAdapterConfig(config) && (
<Flex flexDir="column" gap={2} w="full">
{!isFLUX && <IPAdapterMethod method={config.method} onChange={onChangeIPMethod} />}
<Weight weight={config.weight} onChange={onChangeWeight} />
<BeginEndStepPct beginEndStepPct={config.beginEndStepPct} onChange={onChangeBeginEndStepPct} />
</Flex>
)}
{isFLUXReduxConfig(config) && (
<Flex flexDir="column" gap={2} w="full" alignItems="flex-start">
<FLUXReduxImageInfluence
imageInfluence={config.imageInfluence ?? 'lowest'}
onChange={onChangeFLUXReduxImageInfluence}
/>
</Flex>
)}
<Flex alignItems="center" justifyContent="center" h={32} w={32} aspectRatio="1/1" flexGrow={1}>
<RefImageImage
image={config.image}
onChangeImage={onChangeImage}
dndTarget={setGlobalReferenceImageDndTarget}
dndTargetData={dndTargetData}
/>
</Flex>
</Flex>
</Flex>
);
});
RefImageSettingsContent.displayName = 'RefImageSettingsContent';
const buildSelectIPAdapterHasImage = (id: string) =>
createSelector(selectRefImagesSlice, (refImages) => {
const referenceImage = selectRefImageEntity(refImages, id);
return !!referenceImage && referenceImage.config.image !== null;
});
export const RefImageSettings = memo(() => {
const id = useRefImageIdContext();
const tab = useAppSelector(selectActiveTab);
const canvasManager = useCanvasManagerSafe();
const selectIPAdapterHasImage = useMemo(() => buildSelectIPAdapterHasImage(id), [id]);
const hasImage = useAppSelector(selectIPAdapterHasImage);
if (!hasImage && canvasManager && tab === 'canvas') {
return (
<CanvasManagerProviderGate>
<RefImageNoImageStateWithCanvasOptions />
</CanvasManagerProviderGate>
);
}
if (!hasImage) {
return <RefImageNoImageState />;
}
return <RefImageSettingsContent />;
});
RefImageSettings.displayName = 'RefImageSettings';

View File

@@ -0,0 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectRefImageEntityOrThrow, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
import { useMemo } from 'react';
export const useRefImageEntity = (id: string) => {
const selectEntity = useMemo(
() =>
createSelector(selectRefImagesSlice, (refImages) =>
selectRefImageEntityOrThrow(refImages, id, `useRefImageState(${id})`)
),
[id]
);
const entity = useAppSelector(selectEntity);
return entity;
};

View File

@@ -3,9 +3,9 @@ import { useAppSelector } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import {
buildSelectValidRegionalGuidanceActions,
useAddRegionalGuidanceIPAdapter,
useAddRegionalGuidanceNegativePrompt,
useAddRegionalGuidancePositivePrompt,
useAddNegativePromptToExistingRegionalGuidance,
useAddPositivePromptToExistingRegionalGuidance,
useAddRefImageToExistingRegionalGuidance,
} from 'features/controlLayers/hooks/addLayerHooks';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,9 +14,9 @@ import { PiPlusBold } from 'react-icons/pi';
export const RegionalGuidanceAddPromptsIPAdapterButtons = () => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const { t } = useTranslation();
const addRegionalGuidanceIPAdapter = useAddRegionalGuidanceIPAdapter(entityIdentifier);
const addRegionalGuidancePositivePrompt = useAddRegionalGuidancePositivePrompt(entityIdentifier);
const addRegionalGuidanceNegativePrompt = useAddRegionalGuidanceNegativePrompt(entityIdentifier);
const addRegionalGuidanceIPAdapter = useAddRefImageToExistingRegionalGuidance(entityIdentifier);
const addRegionalGuidancePositivePrompt = useAddPositivePromptToExistingRegionalGuidance(entityIdentifier);
const addRegionalGuidanceNegativePrompt = useAddNegativePromptToExistingRegionalGuidance(entityIdentifier);
const selectValidActions = useMemo(
() => buildSelectValidRegionalGuidanceActions(entityIdentifier),

View File

@@ -2,25 +2,25 @@ import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence';
import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel';
import { Weight } from 'features/controlLayers/components/common/Weight';
import { CLIPVisionModel } from 'features/controlLayers/components/IPAdapter/CLIPVisionModel';
import { FLUXReduxImageInfluence } from 'features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence';
import { IPAdapterImagePreview } from 'features/controlLayers/components/IPAdapter/IPAdapterImagePreview';
import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod';
import { RegionalReferenceImageModel } from 'features/controlLayers/components/IPAdapter/RegionalReferenceImageModel';
import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod';
import { RefImageImage } from 'features/controlLayers/components/RefImage/RefImageImage';
import { RegionalGuidanceIPAdapterSettingsEmptyState } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState';
import { RegionalReferenceImageModel } from 'features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { usePullBboxIntoRegionalGuidanceReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
rgIPAdapterBeginEndStepPctChanged,
rgIPAdapterCLIPVisionModelChanged,
rgIPAdapterDeleted,
rgIPAdapterFLUXReduxImageInfluenceChanged,
rgIPAdapterImageChanged,
rgIPAdapterMethodChanged,
rgIPAdapterModelChanged,
rgIPAdapterWeightChanged,
rgRefImageDeleted,
rgRefImageFLUXReduxImageInfluenceChanged,
rgRefImageImageChanged,
rgRefImageIPAdapterBeginEndStepPctChanged,
rgRefImageIPAdapterCLIPVisionModelChanged,
rgRefImageIPAdapterMethodChanged,
rgRefImageIPAdapterWeightChanged,
rgRefImageModelChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors';
import type {
@@ -46,64 +46,64 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onDeleteIPAdapter = useCallback(() => {
dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId }));
dispatch(rgRefImageDeleted({ entityIdentifier, referenceImageId }));
}, [dispatch, entityIdentifier, referenceImageId]);
const selectIPAdapter = useMemo(
const selectConfig = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
const referenceImage = selectRegionalGuidanceReferenceImage(canvas, entityIdentifier, referenceImageId);
assert(referenceImage, `Regional Guidance IP Adapter with id ${referenceImageId} not found`);
return referenceImage.ipAdapter;
return referenceImage.config;
}),
[entityIdentifier, referenceImageId]
);
const ipAdapter = useAppSelector(selectIPAdapter);
const config = useAppSelector(selectConfig);
const onChangeBeginEndStepPct = useCallback(
(beginEndStepPct: [number, number]) => {
dispatch(rgIPAdapterBeginEndStepPctChanged({ entityIdentifier, referenceImageId, beginEndStepPct }));
dispatch(rgRefImageIPAdapterBeginEndStepPctChanged({ entityIdentifier, referenceImageId, beginEndStepPct }));
},
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeWeight = useCallback(
(weight: number) => {
dispatch(rgIPAdapterWeightChanged({ entityIdentifier, referenceImageId, weight }));
dispatch(rgRefImageIPAdapterWeightChanged({ entityIdentifier, referenceImageId, weight }));
},
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeIPMethod = useCallback(
(method: IPMethodV2) => {
dispatch(rgIPAdapterMethodChanged({ entityIdentifier, referenceImageId, method }));
dispatch(rgRefImageIPAdapterMethodChanged({ entityIdentifier, referenceImageId, method }));
},
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeFLUXReduxImageInfluence = useCallback(
(imageInfluence: FLUXReduxImageInfluenceType) => {
dispatch(rgIPAdapterFLUXReduxImageInfluenceChanged({ entityIdentifier, referenceImageId, imageInfluence }));
dispatch(rgRefImageFLUXReduxImageInfluenceChanged({ entityIdentifier, referenceImageId, imageInfluence }));
},
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeModel = useCallback(
(modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig) => {
dispatch(rgIPAdapterModelChanged({ entityIdentifier, referenceImageId, modelConfig }));
dispatch(rgRefImageModelChanged({ entityIdentifier, referenceImageId, modelConfig }));
},
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeCLIPVisionModel = useCallback(
(clipVisionModel: CLIPVisionModelV2) => {
dispatch(rgIPAdapterCLIPVisionModelChanged({ entityIdentifier, referenceImageId, clipVisionModel }));
dispatch(rgRefImageIPAdapterCLIPVisionModelChanged({ entityIdentifier, referenceImageId, clipVisionModel }));
},
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeImage = useCallback(
(imageDTO: ImageDTO | null) => {
dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO }));
dispatch(rgRefImageImageChanged({ entityIdentifier, referenceImageId, imageDTO }));
},
[dispatch, entityIdentifier, referenceImageId]
);
@@ -112,9 +112,9 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
() =>
setRegionalGuidanceReferenceImageDndTarget.getData(
{ entityIdentifier, referenceImageId },
ipAdapter.image?.image_name
config.image?.image_name
),
[entityIdentifier, ipAdapter.image?.image_name, referenceImageId]
[entityIdentifier, config.image?.image_name, referenceImageId]
);
const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId);
@@ -140,9 +140,9 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
</Flex>
<Flex flexDir="column" gap={2} position="relative" w="full">
<Flex gap={2} alignItems="center" w="full">
<RegionalReferenceImageModel modelKey={ipAdapter.model?.key ?? null} onChangeModel={onChangeModel} />
{ipAdapter.type === 'ip_adapter' && (
<CLIPVisionModel model={ipAdapter.clipVisionModel} onChange={onChangeCLIPVisionModel} />
<RegionalReferenceImageModel modelKey={config.model?.key ?? null} onChangeModel={onChangeModel} />
{config.type === 'ip_adapter' && (
<IPAdapterCLIPVisionModel model={config.clipVisionModel} onChange={onChangeCLIPVisionModel} />
)}
<IconButton
onClick={pullBboxIntoIPAdapter}
@@ -154,24 +154,24 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
/>
</Flex>
<Flex gap={2} w="full">
{ipAdapter.type === 'ip_adapter' && (
{config.type === 'ip_adapter' && (
<Flex flexDir="column" gap={2} w="full">
<IPAdapterMethod method={ipAdapter.method} onChange={onChangeIPMethod} />
<Weight weight={ipAdapter.weight} onChange={onChangeWeight} />
<BeginEndStepPct beginEndStepPct={ipAdapter.beginEndStepPct} onChange={onChangeBeginEndStepPct} />
<IPAdapterMethod method={config.method} onChange={onChangeIPMethod} />
<Weight weight={config.weight} onChange={onChangeWeight} />
<BeginEndStepPct beginEndStepPct={config.beginEndStepPct} onChange={onChangeBeginEndStepPct} />
</Flex>
)}
{ipAdapter.type === 'flux_redux' && (
{config.type === 'flux_redux' && (
<Flex flexDir="column" gap={2} w="full">
<FLUXReduxImageInfluence
imageInfluence={ipAdapter.imageInfluence ?? 'lowest'}
imageInfluence={config.imageInfluence ?? 'lowest'}
onChange={onChangeFLUXReduxImageInfluence}
/>
</Flex>
)}
<Flex alignItems="center" justifyContent="center" h={32} w={32} aspectRatio="1/1" flexGrow={1}>
<IPAdapterImagePreview
image={ipAdapter.image}
<RefImageImage
image={config.image}
onChangeImage={onChangeImage}
dndTarget={setRegionalGuidanceReferenceImageDndTarget}
dndTargetData={dndTargetData}
@@ -191,17 +191,16 @@ const buildSelectIPAdapterHasImage = (
) =>
createSelector(selectCanvasSlice, (canvas) => {
const referenceImage = selectRegionalGuidanceReferenceImage(canvas, entityIdentifier, referenceImageId);
return !!referenceImage && referenceImage.ipAdapter.image !== null;
return !!referenceImage && referenceImage.config.image !== null;
});
export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Props) => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const selectIPAdapterHasImage = useMemo(
const selectHasImage = useMemo(
() => buildSelectIPAdapterHasImage(entityIdentifier, referenceImageId),
[entityIdentifier, referenceImageId]
);
const hasImage = useAppSelector(selectIPAdapterHasImage);
const hasImage = useAppSelector(selectHasImage);
if (!hasImage) {
return <RegionalGuidanceIPAdapterSettingsEmptyState referenceImageId={referenceImageId} />;

View File

@@ -4,7 +4,7 @@ import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { usePullBboxIntoRegionalGuidanceReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { rgIPAdapterDeleted } from 'features/controlLayers/store/canvasSlice';
import { rgRefImageDeleted } from 'features/controlLayers/store/canvasSlice';
import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
@@ -35,7 +35,7 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const onDeleteIPAdapter = useCallback(() => {
dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId }));
dispatch(rgRefImageDeleted({ entityIdentifier, referenceImageId }));
}, [dispatch, entityIdentifier, referenceImageId]);
const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId);
@@ -83,7 +83,7 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
</Flex>
<Flex alignItems="center" gap={2} p={4}>
<Text textAlign="center" color="base.300">
<Trans i18nKey="controlLayers.referenceImageEmptyState" components={components} />
<Trans i18nKey="controlLayers.referenceImageEmptyStateWithCanvasTab" components={components} />
</Text>
</Flex>
<input {...uploadApi.getUploadInputProps()} />

View File

@@ -3,9 +3,9 @@ import { useAppSelector } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import {
buildSelectValidRegionalGuidanceActions,
useAddRegionalGuidanceIPAdapter,
useAddRegionalGuidanceNegativePrompt,
useAddRegionalGuidancePositivePrompt,
useAddNegativePromptToExistingRegionalGuidance,
useAddPositivePromptToExistingRegionalGuidance,
useAddRefImageToExistingRegionalGuidance,
} from 'features/controlLayers/hooks/addLayerHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { memo, useMemo } from 'react';
@@ -15,9 +15,9 @@ export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const { t } = useTranslation();
const isBusy = useCanvasIsBusy();
const addRegionalGuidanceIPAdapter = useAddRegionalGuidanceIPAdapter(entityIdentifier);
const addRegionalGuidancePositivePrompt = useAddRegionalGuidancePositivePrompt(entityIdentifier);
const addRegionalGuidanceNegativePrompt = useAddRegionalGuidanceNegativePrompt(entityIdentifier);
const addRegionalGuidanceIPAdapter = useAddRefImageToExistingRegionalGuidance(entityIdentifier);
const addRegionalGuidancePositivePrompt = useAddPositivePromptToExistingRegionalGuidance(entityIdentifier);
const addRegionalGuidanceNegativePrompt = useAddNegativePromptToExistingRegionalGuidance(entityIdentifier);
const selectValidActions = useMemo(
() => buildSelectValidRegionalGuidanceActions(entityIdentifier),
[entityIdentifier]

View File

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

View File

@@ -0,0 +1,50 @@
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 { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
export const InitialState = memo(() => {
const dispatch = useAppDispatch();
const newCanvasSession = useCallback(() => {
dispatch(setActiveTab('canvas'));
}, [dispatch]);
return (
<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}>
<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>
<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?
</Text>
<Button variant="link" onClick={newCanvasSession}>
Navigate to Canvas for more capabilities.
</Button>
</Alert>
</Flex>
</Flex>
</Flex>
);
});
InitialState.displayName = 'InitialState';

View File

@@ -0,0 +1,49 @@
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 { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { addGlobalReferenceImageDndTarget, newCanvasFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { memo, useMemo } from 'react';
import { PiUploadBold, PiUserCircleGearBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
const dndTargetData = addGlobalReferenceImageDndTarget.getData();
export const InitialStateAddAStyleReference = memo(() => {
const { dispatch, getState } = useAppStore();
const uploadOptions = useMemo(
() =>
({
onUpload: (imageDTO: ImageDTO) => {
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
},
allowMultiple: false,
}) as const,
[dispatch, getState]
);
const uploadApi = useImageUploadButton(uploadOptions);
return (
<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>
<Text color="base.300">Add an image to transfer its look.</Text>
</Flex>
<Flex position="absolute" right={3} bottom={3}>
<PiUploadBold />
<input {...uploadApi.getUploadInputProps()} />
</Flex>
<DndDropTarget dndTarget={newCanvasFromImageDndTarget} dndTargetData={dndTargetData} label="Drop" />
</InitialStateButtonGridItem>
);
});
InitialStateAddAStyleReference.displayName = 'InitialStateAddAStyleReference';

View File

@@ -0,0 +1,29 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button, forwardRef } from '@invoke-ai/ui-library';
import { memo } from 'react';
export const InitialStateButtonGridItem = memo(
forwardRef(({ children, ...rest }: ButtonProps, ref) => {
return (
<Button
ref={ref}
variant="outline"
display="flex"
position="relative"
alignItems="center"
borderWidth={1}
borderRadius="base"
p={4}
pt={6}
gap={2}
w="full"
h="full"
{...rest}
>
{children}
</Button>
);
})
);
InitialStateButtonGridItem.displayName = 'InitialStateButtonGridItem';

View File

@@ -0,0 +1,40 @@
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';
import { memo, useCallback } from 'react';
import { PiPencilBold, PiUploadBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
const NEW_CANVAS_OPTIONS = { type: 'raster_layer', withInpaintMask: true } as const;
const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS);
export const InitialStateEditImageCard = memo(() => {
const { getState, dispatch } = useAppStore();
const onUpload = useCallback(
(imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS });
},
[dispatch, getState]
);
const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload });
return (
<InitialStateButtonGridItem {...uploadApi.getUploadButtonProps()}>
<Icon as={PiPencilBold} boxSize={8} color="base.500" />
<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" />
</InitialStateButtonGridItem>
);
});
InitialStateEditImageCard.displayName = 'InitialStateEditImageCard';

View File

@@ -0,0 +1,28 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem';
import { memo } from 'react';
import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi';
const focusOnPrompt = () => {
const promptElement = document.getElementById('prompt');
if (promptElement instanceof HTMLTextAreaElement) {
promptElement.focus();
promptElement.select();
}
};
export const InitialStateGenerateFromText = memo(() => {
return (
<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>
<Text color="base.300">Enter a prompt and Invoke.</Text>
</Flex>
<Flex position="absolute" right={3} bottom={3}>
<PiCursorTextBold />
</Flex>
</InitialStateButtonGridItem>
);
});
InitialStateGenerateFromText.displayName = 'InitialStateGenerateFromText';

View File

@@ -0,0 +1,47 @@
import { Flex, FormControl, FormLabel, Icon } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { ModelPicker } from 'features/parameters/components/ModelPicker';
import { modelSelected } from 'features/parameters/store/actions';
import { memo, useCallback, useMemo } from 'react';
import { MdMoneyOff } from 'react-icons/md';
import { useMainModels } from 'services/api/hooks/modelsByType';
import { useSelectedModelConfig } from 'services/api/hooks/useSelectedModelConfig';
import { type AnyModelConfig, isCheckpointMainModelConfig } from 'services/api/types';
export const InitialStateMainModelPicker = memo(() => {
const dispatch = useAppDispatch();
const [modelConfigs] = useMainModels();
const selectedModelConfig = useSelectedModelConfig();
const onChange = useCallback(
(modelConfig: AnyModelConfig) => {
dispatch(modelSelected(modelConfig));
},
[dispatch]
);
const isFluxDevSelected = useMemo(
() =>
selectedModelConfig &&
isCheckpointMainModelConfig(selectedModelConfig) &&
selectedModelConfig.config_path === 'flux-dev',
[selectedModelConfig]
);
return (
<FormControl orientation="vertical" alignItems="unset">
<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>
);
});
InitialStateMainModelPicker.displayName = 'InitialStateMainModelPicker';

View File

@@ -0,0 +1,40 @@
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';
import { memo, useCallback } from 'react';
import { PiRectangleDashedBold, PiUploadBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
const NEW_CANVAS_OPTIONS = { type: 'control_layer', withResize: true } as const;
const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS);
export const InitialStateUseALayoutImageCard = memo(() => {
const { getState, dispatch } = useAppStore();
const onUpload = useCallback(
(imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS });
},
[dispatch, getState]
);
const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload });
return (
<InitialStateButtonGridItem {...uploadApi.getUploadButtonProps()}>
<Icon as={PiRectangleDashedBold} boxSize={8} color="base.500" />
<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" />
</InitialStateButtonGridItem>
);
});
InitialStateUseALayoutImageCard.displayName = 'InitialStateUseALayoutImageCard';

View File

@@ -0,0 +1,42 @@
import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
import { getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared';
import { memo } from 'react';
import type { S } from 'services/api/types';
const circleStyles: SystemStyleObject = {
circle: {
transitionProperty: 'none',
transitionDuration: '0s',
},
position: 'absolute',
top: 2,
right: 2,
};
type Props = { itemId: number; status: S['SessionQueueItem']['status'] } & CircularProgressProps;
export const QueueItemCircularProgress = memo(({ itemId, status, ...rest }: Props) => {
const { $progressData } = useCanvasSessionContext();
const { progressEvent } = useProgressData($progressData, itemId);
if (status !== 'in_progress') {
return null;
}
return (
<Tooltip label={getProgressMessage(progressEvent)}>
<CircularProgress
size="14px"
color="invokeBlue.500"
thickness={14}
isIndeterminate={!progressEvent || progressEvent.percentage === null}
value={progressEvent?.percentage ? progressEvent.percentage * 100 : undefined}
sx={circleStyles}
{...rest}
/>
</Tooltip>
);
});
QueueItemCircularProgress.displayName = 'QueueItemCircularProgress';

View File

@@ -0,0 +1,9 @@
import type { TextProps } from '@invoke-ai/ui-library';
import { Text } from '@invoke-ai/ui-library';
import { DROP_SHADOW } from 'features/controlLayers/components/SimpleSession/shared';
import { memo } from 'react';
export const QueueItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => {
return <Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>{`#${number}`}</Text>;
});
QueueItemNumber.displayName = 'QueueItemNumber';

View File

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

View File

@@ -0,0 +1,63 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import {
useCanvasSessionContext,
useOutputImageDTO,
useProgressData,
} from 'features/controlLayers/components/SimpleSession/context';
import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress';
import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber';
import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import { DndImage } from 'features/dnd/DndImage';
import { memo, useCallback } from 'react';
import type { S } from 'services/api/types';
const sx = {
cursor: 'pointer',
userSelect: 'none',
pos: 'relative',
alignItems: 'center',
justifyContent: 'center',
h: 108,
w: 108,
flexShrink: 0,
borderWidth: 2,
borderRadius: 'base',
bg: 'base.900',
'&[data-selected="true"]': {
borderColor: 'invokeBlue.300',
},
} satisfies SystemStyleObject;
type Props = {
item: S['SessionQueueItem'];
number: number;
isSelected: boolean;
};
export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) => {
const ctx = useCanvasSessionContext();
const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
const imageDTO = useOutputImageDTO(item);
const onClick = useCallback(() => {
ctx.$selectedItemId.set(item.item_id);
}, [ctx.$selectedItemId, item.item_id]);
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}>
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail />}
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}
<QueueItemNumber number={number} position="absolute" top={0} left={1} />
<QueueItemCircularProgress itemId={item.item_id} status={item.status} position="absolute" top={1} right={2} />
</Flex>
);
});
QueueItemPreviewMini.displayName = 'QueueItemPreviewMini';

View File

@@ -0,0 +1,29 @@
import type { ImageProps } from '@invoke-ai/ui-library';
import { Image } from '@invoke-ai/ui-library';
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
import { memo } from 'react';
type Props = { itemId: number } & ImageProps;
export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => {
const ctx = useCanvasSessionContext();
const { progressImage } = useProgressData(ctx.$progressData, itemId);
if (!progressImage) {
return null;
}
return (
<Image
objectFit="contain"
maxH="full"
maxW="full"
draggable={false}
src={progressImage.dataURL}
width={progressImage.width}
height={progressImage.height}
{...rest}
/>
);
});
QueueItemProgressImage.displayName = 'QueueItemProgressImage';

View File

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

View File

@@ -0,0 +1,57 @@
import type { TextProps } from '@invoke-ai/ui-library';
import { Text } from '@invoke-ai/ui-library';
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
import { memo } from 'react';
import type { S } from 'services/api/types';
type Props = { item: S['SessionQueueItem'] } & TextProps;
export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
const ctx = useCanvasSessionContext();
const { progressImage, imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
if (progressImage || imageLoaded) {
return null;
}
if (item.status === 'pending') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}>
Pending
</Text>
);
}
if (item.status === 'canceled') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}>
Canceled
</Text>
);
}
if (item.status === 'failed') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}>
Failed
</Text>
);
}
if (item.status === 'in_progress') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}>
In Progress
</Text>
);
}
if (item.status === 'completed') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeGreen.300" {...rest}>
Completed
</Text>
);
}
return null;
});
QueueItemStatusLabel.displayName = 'QueueItemStatusLabel';

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

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useEffect } from 'react';
export const StagingAreaItemsList = memo(() => {
const canvasManager = useCanvasManagerSafe();
const ctx = useCanvasSessionContext();
const items = useStore(ctx.$items);
const selectedItemId = useStore(ctx.$selectedItemId);
useEffect(() => {
if (!canvasManager) {
return;
}
return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData);
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId]);
return (
<ScrollableContent overflowX="scroll" overflowY="hidden">
<Flex gap={2} w="full" h="full" justifyContent="safe center">
{items.map((item, i) => (
<QueueItemPreviewMini
key={`${item.item_id}-mini`}
item={item}
number={i + 1}
isSelected={selectedItemId === item.item_id}
/>
))}
</Flex>
</ScrollableContent>
);
});
StagingAreaItemsList.displayName = 'StagingAreaItemsList';

View File

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

View File

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

View File

@@ -0,0 +1,522 @@
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 { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
import type { ProgressImage } from 'features/nodes/types/common';
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
import { atom, computed, effect, map, subscribeKeys } from 'nanostores';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { getImageDTOSafe } from 'services/api/endpoints/images';
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';
export type ProgressData = {
itemId: number;
progressEvent: S['InvocationProgressEvent'] | null;
progressImage: ProgressImage | null;
imageDTO: ImageDTO | null;
imageLoaded: boolean;
};
const getInitialProgressData = (itemId: number): ProgressData => ({
itemId,
progressEvent: null,
progressImage: null,
imageDTO: null,
imageLoaded: false,
});
export const useProgressData = ($progressData: ProgressDataMap, itemId: number): ProgressData => {
const getInitialValue = useCallback(
() => $progressData.get()[itemId] ?? getInitialProgressData(itemId),
[$progressData, itemId]
);
const [value, setValue] = useState(getInitialValue);
useEffect(() => {
const unsub = subscribeKeys($progressData, [itemId], (data) => {
const progressData = data[itemId];
if (!progressData) {
return;
}
setValue(progressData);
});
return () => {
unsub();
};
}, [$progressData, itemId]);
return value;
};
const setProgress = ($progressData: ProgressDataMap, data: S['InvocationProgressEvent']) => {
const progressData = $progressData.get();
const current = progressData[data.item_id];
if (current) {
const next = { ...current };
next.progressEvent = data;
if (data.image) {
next.progressImage = data.image;
}
$progressData.set({
...progressData,
[data.item_id]: next,
});
} else {
$progressData.set({
...progressData,
[data.item_id]: {
itemId: data.item_id,
progressEvent: data,
progressImage: data.image ?? null,
imageDTO: null,
imageLoaded: false,
},
});
}
};
export type ProgressDataMap = MapStore<Record<number, ProgressData | undefined>>;
type CanvasSessionContextValue = {
session: { id: string; type: 'simple' | 'advanced' };
$items: Atom<S['SessionQueueItem'][]>;
$itemCount: Atom<number>;
$hasItems: Atom<boolean>;
$progressData: ProgressDataMap;
$selectedItemId: WritableAtom<number | null>;
$selectedItem: Atom<S['SessionQueueItem'] | null>;
$selectedItemIndex: Atom<number | null>;
$selectedItemOutputImageDTO: Atom<ImageDTO | null>;
$autoSwitch: WritableAtom<boolean>;
selectNext: () => void;
selectPrev: () => void;
selectFirst: () => void;
selectLast: () => void;
onImageLoad: (itemId: number) => void;
};
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
export const CanvasSessionContextProvider = memo(
({ id, type, children }: PropsWithChildren<{ id: string; type: 'simple' | 'advanced' }>) => {
/**
* For best performance and interop with the Canvas, which is outside react but needs to interact with the react
* app, all canvas session state is packaged as nanostores atoms. The trickiest part is syncing the queue items
* with a nanostores atom.
*/
const session = useMemo(() => ({ type, id }), [type, id]);
/**
* App store
*/
const store = useAppStore();
const socket = useStore($socket);
const $lastCompletedItemId = 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.
*/
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
/**
* Whether auto-switch is enabled.
*/
const $autoSwitch = useState(() => atom(true))[0];
/**
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
* output images have fully loaded.
*/
const $lastLoadedItemId = useState(() => atom<number | null>(null))[0];
/**
* An ephemeral store of progress events and images for all items in the current session.
*/
const $progressData = useState(() => map<StoreValue<ProgressDataMap>>({}))[0];
/**
* The currently selected queue item's ID, or null if one is not selected.
*/
const $selectedItemId = useState(() => atom<number | null>(null))[0];
/**
* The number of items. Computed from the queue items array.
*/
const $itemCount = useState(() => computed([$items], (items) => items.length))[0];
/**
* Whether there are any items. Computed from the queue items array.
*/
const $hasItems = useState(() => computed([$items], (items) => items.length > 0))[0];
/**
* The currently selected queue item, or null if one is not selected.
*/
const $selectedItem = useState(() =>
computed([$items, $selectedItemId], (items, selectedItemId) => {
if (items.length === 0) {
return null;
}
if (selectedItemId === null) {
return null;
}
return items.find(({ item_id }) => item_id === selectedItemId) ?? null;
})
)[0];
/**
* The currently selected queue item's index in the list of items, or null if one is not selected.
*/
const $selectedItemIndex = useState(() =>
computed([$items, $selectedItemId], (items, selectedItemId) => {
if (items.length === 0) {
return null;
}
if (selectedItemId === null) {
return null;
}
return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null;
})
)[0];
/**
* The currently selected queue item's output image name, or null if one is not selected or there is no output
* image recorded.
*/
const $selectedItemOutputImageDTO = useState(() =>
computed([$selectedItemId, $progressData], (selectedItemId, progressData) => {
if (selectedItemId === null) {
return null;
}
const datum = progressData[selectedItemId];
if (!datum) {
return null;
}
return datum.imageDTO;
})
)[0];
/**
* A redux selector to select all queue items from the RTK Query cache. It's important that this returns stable
* references if possible to reduce re-renders. All derivations of the queue items (e.g. filtering out canceled
* items) should be done in a nanostores computed.
*/
const selectQueueItems = useMemo(
() =>
createSelector(
queueApi.endpoints.listAllQueueItems.select({ destination: session.id }),
({ data }) => data ?? EMPTY_ARRAY
),
[session.id]
);
const selectNext = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const nextIndex = (currentIndex + 1) % items.length;
const nextItem = items[nextIndex];
if (!nextItem) {
return;
}
$selectedItemId.set(nextItem.item_id);
}, [$items, $selectedItemId]);
const selectPrev = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const prevIndex = (currentIndex - 1 + items.length) % items.length;
const prevItem = items[prevIndex];
if (!prevItem) {
return;
}
$selectedItemId.set(prevItem.item_id);
}, [$items, $selectedItemId]);
const selectFirst = useCallback(() => {
const items = $items.get();
const first = items.at(0);
if (!first) {
return;
}
$selectedItemId.set(first.item_id);
}, [$items, $selectedItemId]);
const selectLast = useCallback(() => {
const items = $items.get();
const last = items.at(-1);
if (!last) {
return;
}
$selectedItemId.set(last.item_id);
}, [$items, $selectedItemId]);
const onImageLoad = useCallback(
(itemId: number) => {
const progressData = $progressData.get();
const current = progressData[itemId];
if (current) {
const next = { ...current, imageLoaded: true };
$progressData.setKey(itemId, next);
} else {
$progressData.setKey(itemId, {
...getInitialProgressData(itemId),
imageLoaded: true,
});
}
if ($lastCompletedItemId.get() === itemId) {
$selectedItemId.set(itemId);
$lastCompletedItemId.set(null);
}
},
[$lastCompletedItemId, $progressData, $selectedItemId]
);
// Set up socket listeners
useEffect(() => {
if (!socket) {
return;
}
const onProgress = (data: S['InvocationProgressEvent']) => {
if (data.destination !== session.id) {
return;
}
setProgress($progressData, data);
};
const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
if (data.destination !== session.id) {
return;
}
if (data.status === 'completed') {
$lastCompletedItemId.set(data.item_id);
}
};
socket.on('invocation_progress', onProgress);
socket.on('queue_item_status_changed', onQueueItemStatusChanged);
return () => {
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId, session.id, socket]);
// Set up state subscriptions and effects
useEffect(() => {
let _prevItems: readonly S['SessionQueueItem'][] = [];
// Seed the $items atom with the initial query cache state
$items.set(selectQueueItems(store.getState()));
// Manually keep the $items atom in sync as the query cache is updated
const unsubReduxSyncToItemsAtom = store.subscribe(() => {
const prevItems = $items.get();
const items = selectQueueItems(store.getState());
if (items !== prevItems) {
_prevItems = prevItems;
$items.set(items);
}
});
// Handle cases that could result in a nonexistent queue item being selected.
const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => {
// If there are no items, cannot have a selected item.
if (items.length === 0) {
$selectedItemId.set(null);
return;
}
// If there is no selected item but there are items, select the first one.
if (selectedItemId === null && items.length > 0) {
$selectedItemId.set(items[0]?.item_id ?? null);
return;
}
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
// the above case, selecting the first item if there are any.
if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
if (prevIndex >= items.length) {
prevIndex = items.length - 1;
}
const 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) => {
const progressData = $progressData.get();
const toDelete: number[] = [];
const toUpdate: ProgressData[] = [];
for (const [id, datum] of objectEntries(progressData)) {
if (!datum) {
toDelete.push(id);
continue;
}
const item = items.find(({ item_id }) => item_id === datum.itemId);
if (!item) {
toDelete.push(datum.itemId);
} else if (item.status === 'canceled' || item.status === 'failed') {
toUpdate[datum.itemId] = {
...datum,
progressEvent: null,
progressImage: null,
imageDTO: null,
};
}
}
for (const item of items) {
const datum = progressData[item.item_id];
if (datum) {
if (datum.imageDTO) {
continue;
}
const outputImageName = getOutputImageName(item);
if (!outputImageName) {
continue;
}
const imageDTO = await getImageDTOSafe(outputImageName);
if (!imageDTO) {
continue;
}
toUpdate.push({
...datum,
imageDTO,
});
} else {
const outputImageName = getOutputImageName(item);
if (!outputImageName) {
continue;
}
const imageDTO = await getImageDTOSafe(outputImageName);
if (!imageDTO) {
continue;
}
toUpdate.push({
...getInitialProgressData(item.item_id),
imageDTO,
});
}
}
for (const itemId of toDelete) {
$progressData.setKey(itemId, undefined);
}
for (const datum of toUpdate) {
$progressData.setKey(datum.itemId, datum);
}
});
// We only want to auto-switch to completed queue items once their images have fully loaded to prevent flashes
// of fallback content and/or progress images. The only surefire way to determine when images have fully loaded
// is via the image elements' `onLoad` callback. Images set `$lastLoadedItemId` to their queue item ID in their
// `onLoad` handler, and we listen for that here. If auto-switch is enabled, we then switch the to the item.
//
// TODO: This isn't perfect... we set $lastLoadedItemId in the mini preview component, but the full view
// component still needs to retrieve the image from the browser cache... can result in a flash of the progress
// image...
const unsubHandleAutoSwitch = $lastLoadedItemId.listen((lastLoadedItemId) => {
if (lastLoadedItemId === null) {
return;
}
if ($autoSwitch.get()) {
$selectedItemId.set(lastLoadedItemId);
}
$lastLoadedItemId.set(null);
});
// Create an RTK Query subscription. Without this, the query cache selector will never return anything bc RTK
// doesn't know we care about it.
const { unsubscribe: unsubQueueItemsQuery } = store.dispatch(
queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id })
);
// Clean up all subscriptions and top-level (i.e. non-computed/derived state)
return () => {
unsubHandleAutoSwitch();
unsubQueueItemsQuery();
unsubReduxSyncToItemsAtom();
unsubEnsureSelectedItemIdExists();
unsubCleanUpProgressData();
$items.set([]);
$progressData.set({});
$selectedItemId.set(null);
};
}, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]);
const value = useMemo<CanvasSessionContextValue>(
() => ({
session,
$items,
$hasItems,
$progressData,
$selectedItemId,
$autoSwitch,
$selectedItem,
$selectedItemIndex,
$selectedItemOutputImageDTO,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
onImageLoad,
}),
[
$autoSwitch,
$items,
$hasItems,
$progressData,
$selectedItem,
$selectedItemId,
$selectedItemIndex,
session,
$selectedItemOutputImageDTO,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
onImageLoad,
]
);
return <CanvasSessionContext.Provider value={value}>{children}</CanvasSessionContext.Provider>;
}
);
CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider';
export const useCanvasSessionContext = () => {
const ctx = useContext(CanvasSessionContext);
assert(ctx !== null, "'useCanvasSessionContext' must be used within a CanvasSessionContextProvider");
return ctx;
};
export const useOutputImageDTO = (item: S['SessionQueueItem']) => {
const ctx = useCanvasSessionContext();
const $imageDTO = useState(() =>
computed([ctx.$progressData], (progressData) => progressData[item.item_id]?.imageDTO ?? null)
)[0];
const imageDTO = useStore($imageDTO);
return imageDTO;
};

View File

@@ -0,0 +1,40 @@
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 { objectEntries } from 'tsafe';
export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) => {
if (!data) {
return 'Generating';
}
let message = data.message;
if (data.percentage) {
message += ` (${round(data.percentage * 100)}%)`;
}
return message;
};
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
export const getQueueItemElementId = (itemId: number) => `queue-item-status-card-${itemId}`;
export const getOutputImageName = (item: S['SessionQueueItem']) => {
const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>
isCanvasOutputNodeId(nodeId)
)?.[1][0];
const output = nodeId ? item.session.results[nodeId] : undefined;
if (!output) {
return null;
}
for (const [_name, value] of objectEntries(output)) {
if (isImageField(value)) {
return value.image_name;
}
}
return null;
};

View File

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

View File

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

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