Compare commits

..

103 Commits

Author SHA1 Message Date
psychedelicious
ab4dec4aa6 chore(ui): lint 2025-06-16 19:02:01 +10:00
psychedelicious
3ae6714188 feat(ui): rework simple session initial state 2025-06-16 18:59:01 +10:00
psychedelicious
0b461fa8da fix(ui): invoke button tooltip on generate tab 2025-06-16 18:27:29 +10:00
psychedelicious
26a7e342c3 fix(ui): progress image fixes 2025-06-16 18:25:37 +10:00
psychedelicious
55e5f8be1b feat(ui): make autoswitch on/off
When the invocation cache is used, we might skip all progress images. This can prevent auto-switch-on-first-progress from working, as we don't get any of those events.

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

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

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

View File

@@ -499,7 +499,7 @@ def validate_fields(model_fields: dict[str, FieldInfo], model_type: str) -> None
ui_type = field.json_schema_extra.get("ui_type", None)
if isinstance(ui_type, str) and ui_type.startswith("DEPRECATED_"):
logger.warning(f'"UIType.{ui_type.split("_")[-1]}" is deprecated, ignoring')
logger.warn(f'"UIType.{ui_type.split("_")[-1]}" is deprecated, ignoring')
field.json_schema_extra.pop("ui_type")
return None
@@ -613,7 +613,7 @@ def invocation(
raise InvalidVersionError(f'Invalid version string for node "{invocation_type}": "{version}"') from e
uiconfig["version"] = version
else:
logger.warning(f'No version specified for node "{invocation_type}", using "1.0.0"')
logger.warn(f'No version specified for node "{invocation_type}", using "1.0.0"')
uiconfig["version"] = "1.0.0"
cls.UIConfig = UIConfigBase(**uiconfig)

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ class ImageService(ImageServiceABC):
board_id=board_id, image_name=image_name
)
except Exception as e:
self.__invoker.services.logger.warning(f"Failed to add image to board {board_id}: {str(e)}")
self.__invoker.services.logger.warn(f"Failed to add image to board {board_id}: {str(e)}")
self.__invoker.services.image_files.save(
image_name=image_name, image=image, metadata=metadata, workflow=workflow, graph=graph
)

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
@@ -68,7 +68,7 @@
"cmdk": "^1.1.1",
"compare-versions": "^6.1.1",
"filesize": "^10.1.6",
"fracturedjsonjs": "^4.1.0",
"fracturedjsonjs": "^4.0.2",
"framer-motion": "^11.10.0",
"i18next": "^25.0.1",
"i18next-http-backend": "^3.0.2",

View File

@@ -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
@@ -54,8 +54,8 @@ dependencies:
specifier: ^10.1.6
version: 10.1.6
fracturedjsonjs:
specifier: ^4.1.0
version: 4.1.0
specifier: ^4.0.2
version: 4.0.2
framer-motion:
specifier: ^11.10.0
version: 11.10.0(react-dom@18.3.1)(react@18.3.1)
@@ -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
@@ -5280,8 +5280,8 @@ packages:
signal-exit: 4.1.0
dev: true
/fracturedjsonjs@4.1.0:
resolution: {integrity: sha512-qy6LPA8OOiiyRHt5/sNKDayD7h5r3uHmHxSOLbBsgtU/hkt5vOVWOR51MdfDbeCNfj7k/dKCRbXYm8FBAJcgWQ==}
/fracturedjsonjs@4.0.2:
resolution: {integrity: sha512-+vGJH9wK0EEhbbn50V2sOebLRaar1VL3EXr02kxchIwpkhQk0ItrPjIOtYPYuU9hNFpVzxjrPgzjtMJih+ae4A==}
dev: false
/framer-motion@10.18.0(react-dom@18.3.1)(react@18.3.1):

View File

@@ -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

@@ -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,18 +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 { CanvasBusySpinner } from 'features/controlLayers/components/CanvasBusySpinner';
import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems';
import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems';
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
@@ -20,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>
@@ -46,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);
@@ -73,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
@@ -98,7 +94,6 @@ export const CanvasMainPanelContent = memo(() => {
{showHUD && <CanvasHUD />}
<CanvasAlertsSelectedEntityStatus />
<CanvasAlertsPreserveMask />
<CanvasAlertsSendingToGallery />
<CanvasAlertsInvocationProgress />
</Flex>
<Flex position="absolute" top={1} insetInlineEnd={1}>
@@ -107,20 +102,33 @@ export const CanvasMainPanelContent = memo(() => {
<MenuContent />
</Menu>
</Flex>
<Flex position="absolute" bottom={4} insetInlineEnd={4}>
<CanvasBusySpinner />
</Flex>
</CanvasManagerProviderGate>
</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 />
@@ -131,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

@@ -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';

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