mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-20 05:18:17 -05:00
Compare commits
192 Commits
psyche/can
...
psyche/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d7fab524 | ||
|
|
f20c230f4a | ||
|
|
05c9bc730e | ||
|
|
f17ac06591 | ||
|
|
b35f93d919 | ||
|
|
289d8076d8 | ||
|
|
604763d20f | ||
|
|
7b452f098d | ||
|
|
b41c18d35f | ||
|
|
8328081333 | ||
|
|
07517cf2c2 | ||
|
|
6b98ad9095 | ||
|
|
0de3967e7e | ||
|
|
1335377fb1 | ||
|
|
adbcc191d9 | ||
|
|
11fc7af1c8 | ||
|
|
6f12fd22b9 | ||
|
|
324b6e2af4 | ||
|
|
038010a1ca | ||
|
|
2dd1bc54c9 | ||
|
|
8b69842678 | ||
|
|
9821f7c4fc | ||
|
|
2290ff4ad6 | ||
|
|
8d82ad6d0b | ||
|
|
8ed9f652e8 | ||
|
|
ee8ed344bd | ||
|
|
6d16cfdbe2 | ||
|
|
3ef2872dda | ||
|
|
b52ba149b4 | ||
|
|
c6126c6875 | ||
|
|
3f78ac9295 | ||
|
|
79fea1ac40 | ||
|
|
6eade5781d | ||
|
|
3d8f865fb0 | ||
|
|
dc9cd22d9d | ||
|
|
fe115ff8f9 | ||
|
|
1d35aad213 | ||
|
|
195d6ce893 | ||
|
|
f13ced7ed4 | ||
|
|
735fc276e5 | ||
|
|
cd3caf8c30 | ||
|
|
e9012280ab | ||
|
|
fa72a97794 | ||
|
|
e817631ba3 | ||
|
|
d0619c033f | ||
|
|
6f4850f34f | ||
|
|
072cd9dee7 | ||
|
|
19b6dc1c1f | ||
|
|
7566d0d6c6 | ||
|
|
f123888b46 | ||
|
|
aeab7d0cab | ||
|
|
3f1b2c39ab | ||
|
|
72e3a4b4be | ||
|
|
58e0f80138 | ||
|
|
8b8e29d22d | ||
|
|
90201be670 | ||
|
|
46a5619100 | ||
|
|
d608a7469e | ||
|
|
a7d413d372 | ||
|
|
f5c9e68dbf | ||
|
|
1ded459f03 | ||
|
|
d9024dc230 | ||
|
|
40528692c3 | ||
|
|
f35b05be43 | ||
|
|
29e87fc615 | ||
|
|
ca26b2718e | ||
|
|
5fa6c0b413 | ||
|
|
c37c8c50cd | ||
|
|
f0a4de245d | ||
|
|
5db62f8643 | ||
|
|
e1c478f94c | ||
|
|
11fe3b6332 | ||
|
|
e4aae1a591 | ||
|
|
4d83d1c56d | ||
|
|
34def323e8 | ||
|
|
854956316b | ||
|
|
91afe7884a | ||
|
|
8417ee8a7b | ||
|
|
a035645ed3 | ||
|
|
e00ccba7d3 | ||
|
|
fb883d63aa | ||
|
|
b113c57fc4 | ||
|
|
7636007349 | ||
|
|
fda86ae981 | ||
|
|
c02be4bdf4 | ||
|
|
ed7772d993 | ||
|
|
baae998b5b | ||
|
|
4077ffe595 | ||
|
|
c1937b1379 | ||
|
|
5c66dfed8e | ||
|
|
126dcc96c0 | ||
|
|
cb9c7b4a28 | ||
|
|
e8c4f49a14 | ||
|
|
30fffae637 | ||
|
|
4558a292b6 | ||
|
|
825d17441c | ||
|
|
9b16504af9 | ||
|
|
46c92fadff | ||
|
|
c0467b82ac | ||
|
|
6dafa67286 | ||
|
|
eb406aa07e | ||
|
|
d9422ffebd | ||
|
|
d5c033be4d | ||
|
|
4662cd6f15 | ||
|
|
a740a22613 | ||
|
|
bf4016b4bc | ||
|
|
6fa7c8c2ee | ||
|
|
ea40f582da | ||
|
|
01caf56251 | ||
|
|
42d577e65a | ||
|
|
38d80c9ce5 | ||
|
|
6acaa8abbf | ||
|
|
4b84e34599 | ||
|
|
bbd21b1eb2 | ||
|
|
4fa83a6228 | ||
|
|
051876dcff | ||
|
|
8dc6d0b5ae | ||
|
|
40e9624954 | ||
|
|
ae27c83dc4 | ||
|
|
161059551b | ||
|
|
c196f8a5d5 | ||
|
|
2c6d22664e | ||
|
|
b9ce5389ef | ||
|
|
d1cbf56695 | ||
|
|
e379ac12c3 | ||
|
|
aa10373292 | ||
|
|
780f3692a0 | ||
|
|
3604dcfdd1 | ||
|
|
2b1cffde5e | ||
|
|
83d642ed15 | ||
|
|
455c73235e | ||
|
|
8efef8da41 | ||
|
|
060a9e57b9 | ||
|
|
099d75ca1e | ||
|
|
bbb5d68146 | ||
|
|
9066dc1839 | ||
|
|
075345bffd | ||
|
|
74d1239c87 | ||
|
|
51e1c56636 | ||
|
|
ca1df60e54 | ||
|
|
7549c1250d | ||
|
|
df8751b5a1 | ||
|
|
651b80b997 | ||
|
|
5d236ae4e7 | ||
|
|
e5dc606f5e | ||
|
|
dc6b8e13bd | ||
|
|
c1b34e1f11 | ||
|
|
89f1684072 | ||
|
|
14fbee17a3 | ||
|
|
5dbc32e06e | ||
|
|
23baf61e51 | ||
|
|
5e55f6074b | ||
|
|
f7c555e501 | ||
|
|
6aa605e811 | ||
|
|
f51014e108 | ||
|
|
9862ba9210 | ||
|
|
920aea08cc | ||
|
|
39e584297e | ||
|
|
62a14bb935 | ||
|
|
d7ae2cdf75 | ||
|
|
6172c859ac | ||
|
|
b26fb1f617 | ||
|
|
05167dfd7a | ||
|
|
c090ea7387 | ||
|
|
7ba6c67049 | ||
|
|
3de186061d | ||
|
|
a716381733 | ||
|
|
fb5df06835 | ||
|
|
33c597c224 | ||
|
|
19d882d038 | ||
|
|
ee4bc49bd4 | ||
|
|
188cf37f48 | ||
|
|
15a0a7134c | ||
|
|
22cea0de8b | ||
|
|
cd21816d12 | ||
|
|
605b912ba4 | ||
|
|
52e31112f9 | ||
|
|
a4c9346cd7 | ||
|
|
a1647e4c6e | ||
|
|
8c9ca088a7 | ||
|
|
7a7a2e147c | ||
|
|
adf4cc750a | ||
|
|
9f1ea9d1c7 | ||
|
|
571d286506 | ||
|
|
1320a2c5f8 | ||
|
|
26a9b3131d | ||
|
|
d48140b35d | ||
|
|
9757bb0325 | ||
|
|
38ccd8e09c | ||
|
|
7759b166a9 | ||
|
|
9fc51c7a6e | ||
|
|
62fa4f42f5 |
@@ -35,7 +35,7 @@ More detail on system requirements can be found [here](./requirements.md).
|
||||
|
||||
## Step 2: Download
|
||||
|
||||
Download the most launcher for your operating system:
|
||||
Download the most recent launcher for your operating system:
|
||||
|
||||
- [Download for Windows](https://download.invoke.ai/Invoke%20Community%20Edition.exe)
|
||||
- [Download for macOS](https://download.invoke.ai/Invoke%20Community%20Edition.dmg)
|
||||
|
||||
@@ -41,6 +41,7 @@ from invokeai.backend.model_manager.starter_models import (
|
||||
STARTER_BUNDLES,
|
||||
STARTER_MODELS,
|
||||
StarterModel,
|
||||
StarterModelBundle,
|
||||
StarterModelWithoutDependencies,
|
||||
)
|
||||
|
||||
@@ -799,7 +800,7 @@ async def convert_model(
|
||||
|
||||
class StarterModelResponse(BaseModel):
|
||||
starter_models: list[StarterModel]
|
||||
starter_bundles: dict[str, list[StarterModel]]
|
||||
starter_bundles: dict[str, StarterModelBundle]
|
||||
|
||||
|
||||
def get_is_installed(
|
||||
@@ -833,7 +834,7 @@ async def get_starter_models() -> StarterModelResponse:
|
||||
model.dependencies = missing_deps
|
||||
|
||||
for bundle in starter_bundles.values():
|
||||
for model in bundle:
|
||||
for model in bundle.models:
|
||||
model.is_installed = get_is_installed(model, installed_models)
|
||||
# Remove already-installed dependencies
|
||||
missing_deps: list[StarterModelWithoutDependencies] = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Body, Path, Query
|
||||
from fastapi import Body, HTTPException, Path, Query
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -22,6 +22,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
RetryItemsResult,
|
||||
SessionQueueCountsByDestination,
|
||||
SessionQueueItem,
|
||||
SessionQueueItemNotFoundError,
|
||||
SessionQueueStatus,
|
||||
)
|
||||
from invokeai.app.services.shared.pagination import CursorPaginatedResults
|
||||
@@ -59,10 +60,12 @@ async def enqueue_batch(
|
||||
),
|
||||
) -> EnqueueBatchResult:
|
||||
"""Processes a batch and enqueues the output graphs for execution."""
|
||||
|
||||
return await ApiDependencies.invoker.services.session_queue.enqueue_batch(
|
||||
queue_id=queue_id, batch=batch, prepend=prepend
|
||||
)
|
||||
try:
|
||||
return await ApiDependencies.invoker.services.session_queue.enqueue_batch(
|
||||
queue_id=queue_id, batch=batch, prepend=prepend
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while enqueuing batch: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -82,14 +85,17 @@ async def list_queue_items(
|
||||
) -> 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,
|
||||
destination=destination,
|
||||
)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.list_queue_items(
|
||||
queue_id=queue_id,
|
||||
limit=limit,
|
||||
status=status,
|
||||
cursor=cursor,
|
||||
priority=priority,
|
||||
destination=destination,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while listing all items: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -104,11 +110,13 @@ async def list_all_queue_items(
|
||||
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,
|
||||
)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.list_all_queue_items(
|
||||
queue_id=queue_id,
|
||||
destination=destination,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue items: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -120,7 +128,10 @@ async def resume(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> SessionProcessorStatus:
|
||||
"""Resumes session processor"""
|
||||
return ApiDependencies.invoker.services.session_processor.resume()
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_processor.resume()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while resuming queue: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -132,7 +143,10 @@ async def Pause(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> SessionProcessorStatus:
|
||||
"""Pauses session processor"""
|
||||
return ApiDependencies.invoker.services.session_processor.pause()
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_processor.pause()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while pausing queue: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -144,7 +158,10 @@ async def cancel_all_except_current(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> CancelAllExceptCurrentResult:
|
||||
"""Immediately cancels all queue items except in-processing items"""
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(queue_id=queue_id)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(queue_id=queue_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while canceling all except current: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -156,7 +173,10 @@ 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)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.delete_all_except_current(queue_id=queue_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while deleting all except current: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -169,7 +189,12 @@ async def cancel_by_batch_ids(
|
||||
batch_ids: list[str] = Body(description="The list of batch_ids to cancel all queue items for", embed=True),
|
||||
) -> CancelByBatchIDsResult:
|
||||
"""Immediately cancels all queue items from the given batch ids"""
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_by_batch_ids(queue_id=queue_id, batch_ids=batch_ids)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_by_batch_ids(
|
||||
queue_id=queue_id, batch_ids=batch_ids
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by batch id: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -182,9 +207,12 @@ async def cancel_by_destination(
|
||||
destination: str = Query(description="The destination to cancel all queue items for"),
|
||||
) -> CancelByDestinationResult:
|
||||
"""Immediately cancels all queue items with the given origin"""
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_by_destination(
|
||||
queue_id=queue_id, destination=destination
|
||||
)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_by_destination(
|
||||
queue_id=queue_id, destination=destination
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by destination: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -197,7 +225,10 @@ async def retry_items_by_id(
|
||||
item_ids: list[int] = Body(description="The queue item ids to retry"),
|
||||
) -> RetryItemsResult:
|
||||
"""Immediately cancels all queue items with the given origin"""
|
||||
return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while retrying queue items: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -211,11 +242,14 @@ async def clear(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> ClearResult:
|
||||
"""Clears the queue entirely, immediately canceling the currently-executing session"""
|
||||
queue_item = ApiDependencies.invoker.services.session_queue.get_current(queue_id)
|
||||
if queue_item is not None:
|
||||
ApiDependencies.invoker.services.session_queue.cancel_queue_item(queue_item.item_id)
|
||||
clear_result = ApiDependencies.invoker.services.session_queue.clear(queue_id)
|
||||
return clear_result
|
||||
try:
|
||||
queue_item = ApiDependencies.invoker.services.session_queue.get_current(queue_id)
|
||||
if queue_item is not None:
|
||||
ApiDependencies.invoker.services.session_queue.cancel_queue_item(queue_item.item_id)
|
||||
clear_result = ApiDependencies.invoker.services.session_queue.clear(queue_id)
|
||||
return clear_result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while clearing queue: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -229,7 +263,10 @@ async def prune(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> PruneResult:
|
||||
"""Prunes all completed or errored queue items"""
|
||||
return ApiDependencies.invoker.services.session_queue.prune(queue_id)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.prune(queue_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while pruning queue: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -243,7 +280,10 @@ async def get_current_queue_item(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> Optional[SessionQueueItem]:
|
||||
"""Gets the currently execution queue item"""
|
||||
return ApiDependencies.invoker.services.session_queue.get_current(queue_id)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.get_current(queue_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while getting current queue item: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -257,7 +297,10 @@ async def get_next_queue_item(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> Optional[SessionQueueItem]:
|
||||
"""Gets the next queue item, without executing it"""
|
||||
return ApiDependencies.invoker.services.session_queue.get_next(queue_id)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.get_next(queue_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while getting next queue item: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -271,9 +314,12 @@ async def get_queue_status(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> SessionQueueAndProcessorStatus:
|
||||
"""Gets the status of the session queue"""
|
||||
queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id)
|
||||
processor = ApiDependencies.invoker.services.session_processor.get_status()
|
||||
return SessionQueueAndProcessorStatus(queue=queue, processor=processor)
|
||||
try:
|
||||
queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id)
|
||||
processor = ApiDependencies.invoker.services.session_processor.get_status()
|
||||
return SessionQueueAndProcessorStatus(queue=queue, processor=processor)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while getting queue status: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -288,7 +334,10 @@ async def get_batch_status(
|
||||
batch_id: str = Path(description="The batch to get the status of"),
|
||||
) -> BatchStatus:
|
||||
"""Gets the status of the session queue"""
|
||||
return ApiDependencies.invoker.services.session_queue.get_batch_status(queue_id=queue_id, batch_id=batch_id)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.get_batch_status(queue_id=queue_id, batch_id=batch_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while getting batch status: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -304,7 +353,12 @@ async def get_queue_item(
|
||||
item_id: int = Path(description="The queue item to get"),
|
||||
) -> SessionQueueItem:
|
||||
"""Gets a queue item"""
|
||||
return ApiDependencies.invoker.services.session_queue.get_queue_item(item_id)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.get_queue_item(item_id)
|
||||
except SessionQueueItemNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while fetching queue item: {e}")
|
||||
|
||||
|
||||
@session_queue_router.delete(
|
||||
@@ -316,7 +370,10 @@ async def delete_queue_item(
|
||||
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)
|
||||
try:
|
||||
ApiDependencies.invoker.services.session_queue.delete_queue_item(item_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while deleting queue item: {e}")
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
@@ -331,8 +388,12 @@ async def cancel_queue_item(
|
||||
item_id: int = Path(description="The queue item to cancel"),
|
||||
) -> SessionQueueItem:
|
||||
"""Deletes a queue item"""
|
||||
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id)
|
||||
except SessionQueueItemNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while canceling queue item: {e}")
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -345,9 +406,12 @@ async def counts_by_destination(
|
||||
destination: str = Query(description="The destination to query"),
|
||||
) -> SessionQueueCountsByDestination:
|
||||
"""Gets the counts of queue items by destination"""
|
||||
return ApiDependencies.invoker.services.session_queue.get_counts_by_destination(
|
||||
queue_id=queue_id, destination=destination
|
||||
)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.get_counts_by_destination(
|
||||
queue_id=queue_id, destination=destination
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while fetching counts by destination: {e}")
|
||||
|
||||
|
||||
@session_queue_router.delete(
|
||||
@@ -360,6 +424,9 @@ async def delete_by_destination(
|
||||
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
|
||||
)
|
||||
try:
|
||||
return ApiDependencies.invoker.services.session_queue.delete_by_destination(
|
||||
queue_id=queue_id, destination=destination
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Unexpected error while deleting by destination: {e}")
|
||||
|
||||
@@ -215,6 +215,7 @@ class FieldDescriptions:
|
||||
flux_redux_conditioning = "FLUX Redux conditioning tensor"
|
||||
vllm_model = "The VLLM model to use"
|
||||
flux_fill_conditioning = "FLUX Fill conditioning tensor"
|
||||
flux_kontext_conditioning = "FLUX Kontext conditioning (reference image)"
|
||||
|
||||
|
||||
class ImageField(BaseModel):
|
||||
@@ -291,6 +292,12 @@ class FluxFillConditioningField(BaseModel):
|
||||
mask: TensorField = Field(description="The FLUX Fill inpaint mask.")
|
||||
|
||||
|
||||
class FluxKontextConditioningField(BaseModel):
|
||||
"""A conditioning field for FLUX Kontext (reference image)."""
|
||||
|
||||
image: ImageField = Field(description="The Kontext reference image.")
|
||||
|
||||
|
||||
class SD3ConditioningField(BaseModel):
|
||||
"""A conditioning tensor primitive value"""
|
||||
|
||||
|
||||
@@ -16,13 +16,12 @@ from invokeai.app.invocations.fields import (
|
||||
FieldDescriptions,
|
||||
FluxConditioningField,
|
||||
FluxFillConditioningField,
|
||||
FluxKontextConditioningField,
|
||||
FluxReduxConditioningField,
|
||||
ImageField,
|
||||
Input,
|
||||
InputField,
|
||||
LatentsField,
|
||||
WithBoard,
|
||||
WithMetadata,
|
||||
)
|
||||
from invokeai.app.invocations.flux_controlnet import FluxControlNetField
|
||||
from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation
|
||||
@@ -34,6 +33,7 @@ from invokeai.backend.flux.controlnet.instantx_controlnet_flux import InstantXCo
|
||||
from invokeai.backend.flux.controlnet.xlabs_controlnet_flux import XLabsControlNetFlux
|
||||
from invokeai.backend.flux.denoise import denoise
|
||||
from invokeai.backend.flux.extensions.instantx_controlnet_extension import InstantXControlNetExtension
|
||||
from invokeai.backend.flux.extensions.kontext_extension import KontextExtension
|
||||
from invokeai.backend.flux.extensions.regional_prompting_extension import RegionalPromptingExtension
|
||||
from invokeai.backend.flux.extensions.xlabs_controlnet_extension import XLabsControlNetExtension
|
||||
from invokeai.backend.flux.extensions.xlabs_ip_adapter_extension import XLabsIPAdapterExtension
|
||||
@@ -63,9 +63,9 @@ from invokeai.backend.util.devices import TorchDevice
|
||||
title="FLUX Denoise",
|
||||
tags=["image", "flux"],
|
||||
category="image",
|
||||
version="3.3.0",
|
||||
version="4.0.0",
|
||||
)
|
||||
class FluxDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
class FluxDenoiseInvocation(BaseInvocation):
|
||||
"""Run denoising process with a FLUX transformer model."""
|
||||
|
||||
# If latents is provided, this means we are doing image-to-image.
|
||||
@@ -145,11 +145,20 @@ class FluxDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
description=FieldDescriptions.vae,
|
||||
input=Input.Connection,
|
||||
)
|
||||
# This node accepts a images for features like FLUX Fill, ControlNet, and Kontext, but needs to operate on them in
|
||||
# latent space. We'll run the VAE to encode them in this node instead of requiring the user to run the VAE in
|
||||
# upstream nodes.
|
||||
|
||||
ip_adapter: IPAdapterField | list[IPAdapterField] | None = InputField(
|
||||
description=FieldDescriptions.ip_adapter, title="IP-Adapter", default=None, input=Input.Connection
|
||||
)
|
||||
|
||||
kontext_conditioning: Optional[FluxKontextConditioningField] = InputField(
|
||||
default=None,
|
||||
description="FLUX Kontext conditioning (reference image).",
|
||||
input=Input.Connection,
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def invoke(self, context: InvocationContext) -> LatentsOutput:
|
||||
latents = self._run_diffusion(context)
|
||||
@@ -376,14 +385,34 @@ class FluxDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
dtype=inference_dtype,
|
||||
)
|
||||
|
||||
kontext_extension = None
|
||||
if self.kontext_conditioning is not None:
|
||||
if not self.controlnet_vae:
|
||||
raise ValueError("A VAE (e.g., controlnet_vae) must be provided to use Kontext conditioning.")
|
||||
|
||||
kontext_extension = KontextExtension(
|
||||
kontext_field=self.kontext_conditioning,
|
||||
context=context,
|
||||
vae_field=self.controlnet_vae,
|
||||
device=TorchDevice.choose_torch_device(),
|
||||
dtype=inference_dtype,
|
||||
)
|
||||
|
||||
final_img, final_img_ids = x, img_ids
|
||||
original_seq_len = x.shape[1]
|
||||
if kontext_extension is not None:
|
||||
final_img, final_img_ids = kontext_extension.apply(final_img, final_img_ids)
|
||||
|
||||
x = denoise(
|
||||
model=transformer,
|
||||
img=x,
|
||||
img_ids=img_ids,
|
||||
img=final_img,
|
||||
img_ids=final_img_ids,
|
||||
pos_regional_prompting_extension=pos_regional_prompting_extension,
|
||||
neg_regional_prompting_extension=neg_regional_prompting_extension,
|
||||
timesteps=timesteps,
|
||||
step_callback=self._build_step_callback(context),
|
||||
step_callback=self._build_step_callback(
|
||||
context, original_seq_len if kontext_extension is not None else None
|
||||
),
|
||||
guidance=self.guidance,
|
||||
cfg_scale=cfg_scale,
|
||||
inpaint_extension=inpaint_extension,
|
||||
@@ -393,6 +422,9 @@ class FluxDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
img_cond=img_cond,
|
||||
)
|
||||
|
||||
if kontext_extension is not None:
|
||||
x = x[:, :original_seq_len, :] # Keep only the first original_seq_len tokens
|
||||
|
||||
x = unpack(x.float(), self.height, self.width)
|
||||
return x
|
||||
|
||||
@@ -863,9 +895,15 @@ class FluxDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
yield (lora_info.model, lora.weight)
|
||||
del lora_info
|
||||
|
||||
def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
|
||||
def _build_step_callback(
|
||||
self, context: InvocationContext, original_seq_len: Optional[int] = None
|
||||
) -> Callable[[PipelineIntermediateState], None]:
|
||||
def step_callback(state: PipelineIntermediateState) -> None:
|
||||
state.latents = unpack(state.latents.float(), self.height, self.width).squeeze()
|
||||
# Extract only main image tokens if Kontext conditioning was applied
|
||||
latents = state.latents.float()
|
||||
if original_seq_len is not None:
|
||||
latents = latents[:, :original_seq_len, :]
|
||||
state.latents = unpack(latents, self.height, self.width).squeeze()
|
||||
context.util.flux_step_callback(state)
|
||||
|
||||
return step_callback
|
||||
|
||||
40
invokeai/app/invocations/flux_kontext.py
Normal file
40
invokeai/app/invocations/flux_kontext.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from invokeai.app.invocations.baseinvocation import (
|
||||
BaseInvocation,
|
||||
BaseInvocationOutput,
|
||||
invocation,
|
||||
invocation_output,
|
||||
)
|
||||
from invokeai.app.invocations.fields import (
|
||||
FieldDescriptions,
|
||||
FluxKontextConditioningField,
|
||||
InputField,
|
||||
OutputField,
|
||||
)
|
||||
from invokeai.app.invocations.primitives import ImageField
|
||||
from invokeai.app.services.shared.invocation_context import InvocationContext
|
||||
|
||||
|
||||
@invocation_output("flux_kontext_output")
|
||||
class FluxKontextOutput(BaseInvocationOutput):
|
||||
"""The conditioning output of a FLUX Kontext invocation."""
|
||||
|
||||
kontext_cond: FluxKontextConditioningField = OutputField(
|
||||
description=FieldDescriptions.flux_kontext_conditioning, title="Kontext Conditioning"
|
||||
)
|
||||
|
||||
|
||||
@invocation(
|
||||
"flux_kontext",
|
||||
title="Kontext Conditioning - FLUX",
|
||||
tags=["conditioning", "kontext", "flux"],
|
||||
category="conditioning",
|
||||
version="1.0.0",
|
||||
)
|
||||
class FluxKontextInvocation(BaseInvocation):
|
||||
"""Prepares a reference image for FLUX Kontext conditioning."""
|
||||
|
||||
image: ImageField = InputField(description="The Kontext reference image.")
|
||||
|
||||
def invoke(self, context: InvocationContext) -> FluxKontextOutput:
|
||||
"""Packages the provided image into a Kontext conditioning field."""
|
||||
return FluxKontextOutput(kontext_cond=FluxKontextConditioningField(image=self.image))
|
||||
@@ -1,5 +1,5 @@
|
||||
from contextlib import ExitStack
|
||||
from typing import Iterator, Literal, Optional, Tuple
|
||||
from typing import Iterator, Literal, Optional, Tuple, Union
|
||||
|
||||
import torch
|
||||
from transformers import CLIPTextModel, CLIPTokenizer, T5EncoderModel, T5Tokenizer, T5TokenizerFast
|
||||
@@ -111,6 +111,9 @@ class FluxTextEncoderInvocation(BaseInvocation):
|
||||
|
||||
t5_encoder = HFEncoder(t5_text_encoder, t5_tokenizer, False, self.t5_max_seq_len)
|
||||
|
||||
if context.config.get().log_tokenization:
|
||||
self._log_t5_tokenization(context, t5_tokenizer)
|
||||
|
||||
context.util.signal_progress("Running T5 encoder")
|
||||
prompt_embeds = t5_encoder(prompt)
|
||||
|
||||
@@ -151,6 +154,9 @@ class FluxTextEncoderInvocation(BaseInvocation):
|
||||
|
||||
clip_encoder = HFEncoder(clip_text_encoder, clip_tokenizer, True, 77)
|
||||
|
||||
if context.config.get().log_tokenization:
|
||||
self._log_clip_tokenization(context, clip_tokenizer)
|
||||
|
||||
context.util.signal_progress("Running CLIP encoder")
|
||||
pooled_prompt_embeds = clip_encoder(prompt)
|
||||
|
||||
@@ -170,3 +176,88 @@ class FluxTextEncoderInvocation(BaseInvocation):
|
||||
assert isinstance(lora_info.model, ModelPatchRaw)
|
||||
yield (lora_info.model, lora.weight)
|
||||
del lora_info
|
||||
|
||||
def _log_t5_tokenization(
|
||||
self,
|
||||
context: InvocationContext,
|
||||
tokenizer: Union[T5Tokenizer, T5TokenizerFast],
|
||||
) -> None:
|
||||
"""Logs the tokenization of a prompt for a T5-based model like FLUX."""
|
||||
|
||||
# Tokenize the prompt using the same parameters as the model's text encoder.
|
||||
# T5 tokenizers add an EOS token (</s>) and then pad to max_length.
|
||||
tokenized_output = tokenizer(
|
||||
self.prompt,
|
||||
padding="max_length",
|
||||
max_length=self.t5_max_seq_len,
|
||||
truncation=True,
|
||||
add_special_tokens=True, # This is important for T5 to add the EOS token.
|
||||
return_tensors="pt",
|
||||
)
|
||||
|
||||
input_ids = tokenized_output.input_ids[0]
|
||||
tokens = tokenizer.convert_ids_to_tokens(input_ids)
|
||||
|
||||
# The T5 tokenizer uses a space-like character ' ' (U+2581) to denote spaces.
|
||||
# We'll replace it with a regular space for readability.
|
||||
tokens = [t.replace("\u2581", " ") for t in tokens]
|
||||
|
||||
tokenized_str = ""
|
||||
used_tokens = 0
|
||||
for token in tokens:
|
||||
if token == tokenizer.eos_token:
|
||||
tokenized_str += f"\x1b[0;31m{token}\x1b[0m" # Red for EOS
|
||||
used_tokens += 1
|
||||
elif token == tokenizer.pad_token:
|
||||
# tokenized_str += f"\x1b[0;34m{token}\x1b[0m" # Blue for PAD
|
||||
continue
|
||||
else:
|
||||
color = (used_tokens % 6) + 1 # Cycle through 6 colors
|
||||
tokenized_str += f"\x1b[0;3{color}m{token}\x1b[0m"
|
||||
used_tokens += 1
|
||||
|
||||
context.logger.info(f">> [T5 TOKENLOG] Tokens ({used_tokens}/{self.t5_max_seq_len}):")
|
||||
context.logger.info(f"{tokenized_str}\x1b[0m")
|
||||
|
||||
def _log_clip_tokenization(
|
||||
self,
|
||||
context: InvocationContext,
|
||||
tokenizer: CLIPTokenizer,
|
||||
) -> None:
|
||||
"""Logs the tokenization of a prompt for a CLIP-based model."""
|
||||
max_length = tokenizer.model_max_length
|
||||
|
||||
tokenized_output = tokenizer(
|
||||
self.prompt,
|
||||
padding="max_length",
|
||||
max_length=max_length,
|
||||
truncation=True,
|
||||
return_tensors="pt",
|
||||
)
|
||||
|
||||
input_ids = tokenized_output.input_ids[0]
|
||||
attention_mask = tokenized_output.attention_mask[0]
|
||||
tokens = tokenizer.convert_ids_to_tokens(input_ids)
|
||||
|
||||
# The CLIP tokenizer uses '</w>' to denote spaces.
|
||||
# We'll replace it with a regular space for readability.
|
||||
tokens = [t.replace("</w>", " ") for t in tokens]
|
||||
|
||||
tokenized_str = ""
|
||||
used_tokens = 0
|
||||
for i, token in enumerate(tokens):
|
||||
if attention_mask[i] == 0:
|
||||
# Do not log padding tokens.
|
||||
continue
|
||||
|
||||
if token == tokenizer.bos_token:
|
||||
tokenized_str += f"\x1b[0;32m{token}\x1b[0m" # Green for BOS
|
||||
elif token == tokenizer.eos_token:
|
||||
tokenized_str += f"\x1b[0;31m{token}\x1b[0m" # Red for EOS
|
||||
else:
|
||||
color = (used_tokens % 6) + 1 # Cycle through 6 colors
|
||||
tokenized_str += f"\x1b[0;3{color}m{token}\x1b[0m"
|
||||
used_tokens += 1
|
||||
|
||||
context.logger.info(f">> [CLIP TOKENLOG] Tokens ({used_tokens}/{max_length}):")
|
||||
context.logger.info(f"{tokenized_str}\x1b[0m")
|
||||
|
||||
@@ -332,6 +332,7 @@ class EnqueueBatchResult(BaseModel):
|
||||
requested: int = Field(description="The total number of queue items requested to be enqueued")
|
||||
batch: Batch = Field(description="The batch that was enqueued")
|
||||
priority: int = Field(description="The priority of the enqueued batch")
|
||||
item_ids: list[int] = Field(description="The IDs of the queue items that were enqueued")
|
||||
|
||||
|
||||
class RetryItemsResult(BaseModel):
|
||||
|
||||
@@ -133,6 +133,18 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
values_to_insert,
|
||||
)
|
||||
with self._conn:
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT item_id
|
||||
FROM session_queue
|
||||
WHERE batch_id = ?
|
||||
ORDER BY item_id DESC;
|
||||
""",
|
||||
(batch.batch_id,),
|
||||
)
|
||||
item_ids = [row[0] for row in cursor.fetchall()]
|
||||
except Exception:
|
||||
raise
|
||||
enqueue_result = EnqueueBatchResult(
|
||||
@@ -141,6 +153,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
enqueued=enqueued_count,
|
||||
batch=batch,
|
||||
priority=priority,
|
||||
item_ids=item_ids,
|
||||
)
|
||||
self.__invoker.services.events.emit_batch_enqueued(enqueue_result)
|
||||
return enqueue_result
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import copy
|
||||
import itertools
|
||||
from typing import Any, Optional, TypeVar, Union, get_args, get_origin, get_type_hints
|
||||
from typing import Any, Optional, TypeVar, Union, get_args, get_origin
|
||||
|
||||
import networkx as nx
|
||||
from pydantic import (
|
||||
@@ -58,17 +58,32 @@ class Edge(BaseModel):
|
||||
|
||||
|
||||
def get_output_field_type(node: BaseInvocation, field: str) -> Any:
|
||||
node_type = type(node)
|
||||
node_outputs = get_type_hints(node_type.get_output_annotation())
|
||||
node_output_field = node_outputs.get(field) or None
|
||||
return node_output_field
|
||||
# TODO(psyche): This is awkward - if field_info is None, it means the field is not defined in the output, which
|
||||
# really should raise. The consumers of this utility expect it to never raise, and return None instead. Fixing this
|
||||
# would require some fairly significant changes and I don't want risk breaking anything.
|
||||
try:
|
||||
invocation_class = type(node)
|
||||
invocation_output_class = invocation_class.get_output_annotation()
|
||||
field_info = invocation_output_class.model_fields.get(field)
|
||||
assert field_info is not None, f"Output field '{field}' not found in {invocation_output_class.get_type()}"
|
||||
output_field_type = field_info.annotation
|
||||
return output_field_type
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_input_field_type(node: BaseInvocation, field: str) -> Any:
|
||||
node_type = type(node)
|
||||
node_inputs = get_type_hints(node_type)
|
||||
node_input_field = node_inputs.get(field) or None
|
||||
return node_input_field
|
||||
# TODO(psyche): This is awkward - if field_info is None, it means the field is not defined in the output, which
|
||||
# really should raise. The consumers of this utility expect it to never raise, and return None instead. Fixing this
|
||||
# would require some fairly significant changes and I don't want risk breaking anything.
|
||||
try:
|
||||
invocation_class = type(node)
|
||||
field_info = invocation_class.model_fields.get(field)
|
||||
assert field_info is not None, f"Input field '{field}' not found in {invocation_class.get_type()}"
|
||||
input_field_type = field_info.annotation
|
||||
return input_field_type
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_union_subtype(t1, t2):
|
||||
@@ -992,10 +1007,11 @@ class GraphExecutionState(BaseModel):
|
||||
new_node_ids = []
|
||||
if isinstance(next_node, CollectInvocation):
|
||||
# Collapse all iterator input mappings and create a single execution node for the collect invocation
|
||||
all_iteration_mappings = list(
|
||||
itertools.chain(*(((s, p) for p in self.source_prepared_mapping[s]) for s in next_node_parents))
|
||||
)
|
||||
# all_iteration_mappings = list(set(itertools.chain(*prepared_parent_mappings)))
|
||||
all_iteration_mappings = []
|
||||
for source_node_id in next_node_parents:
|
||||
prepared_nodes = self.source_prepared_mapping[source_node_id]
|
||||
all_iteration_mappings.extend([(source_node_id, p) for p in prepared_nodes])
|
||||
|
||||
create_results = self._create_execution_node(next_node_id, all_iteration_mappings)
|
||||
if create_results is not None:
|
||||
new_node_ids.extend(create_results)
|
||||
|
||||
@@ -123,7 +123,11 @@ def calc_percentage(intermediate_state: PipelineIntermediateState) -> float:
|
||||
if total_steps == 0:
|
||||
return 0.0
|
||||
if order == 2:
|
||||
return floor(step / 2) / floor(total_steps / 2)
|
||||
# Prevent division by zero when total_steps is 1 or 2
|
||||
denominator = floor(total_steps / 2)
|
||||
if denominator == 0:
|
||||
return 0.0
|
||||
return floor(step / 2) / denominator
|
||||
# order == 1
|
||||
return step / total_steps
|
||||
|
||||
|
||||
139
invokeai/backend/flux/extensions/kontext_extension.py
Normal file
139
invokeai/backend/flux/extensions/kontext_extension.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import einops
|
||||
import torch
|
||||
from einops import repeat
|
||||
|
||||
from invokeai.app.invocations.fields import FluxKontextConditioningField
|
||||
from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation
|
||||
from invokeai.app.invocations.model import VAEField
|
||||
from invokeai.app.services.shared.invocation_context import InvocationContext
|
||||
from invokeai.backend.flux.sampling_utils import pack
|
||||
from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
|
||||
|
||||
|
||||
def generate_img_ids_with_offset(
|
||||
latent_height: int,
|
||||
latent_width: int,
|
||||
batch_size: int,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
idx_offset: int = 0,
|
||||
) -> torch.Tensor:
|
||||
"""Generate tensor of image position ids with an optional offset.
|
||||
|
||||
Args:
|
||||
latent_height (int): Height of image in latent space (after packing, this becomes h//2).
|
||||
latent_width (int): Width of image in latent space (after packing, this becomes w//2).
|
||||
batch_size (int): Number of images in the batch.
|
||||
device (torch.device): Device to create tensors on.
|
||||
dtype (torch.dtype): Data type for the tensors.
|
||||
idx_offset (int): Offset to add to the first dimension of the image ids.
|
||||
|
||||
Returns:
|
||||
torch.Tensor: Image position ids with shape [batch_size, (latent_height//2 * latent_width//2), 3].
|
||||
"""
|
||||
|
||||
if device.type == "mps":
|
||||
orig_dtype = dtype
|
||||
dtype = torch.float16
|
||||
|
||||
# After packing, the spatial dimensions are halved due to the 2x2 patch structure
|
||||
packed_height = latent_height // 2
|
||||
packed_width = latent_width // 2
|
||||
|
||||
# Create base tensor for position IDs with shape [packed_height, packed_width, 3]
|
||||
# The 3 channels represent: [batch_offset, y_position, x_position]
|
||||
img_ids = torch.zeros(packed_height, packed_width, 3, device=device, dtype=dtype)
|
||||
|
||||
# Set the batch offset for all positions
|
||||
img_ids[..., 0] = idx_offset
|
||||
|
||||
# Create y-coordinate indices (vertical positions)
|
||||
y_indices = torch.arange(packed_height, device=device, dtype=dtype)
|
||||
# Broadcast y_indices to match the spatial dimensions [packed_height, 1]
|
||||
img_ids[..., 1] = y_indices[:, None]
|
||||
|
||||
# Create x-coordinate indices (horizontal positions)
|
||||
x_indices = torch.arange(packed_width, device=device, dtype=dtype)
|
||||
# Broadcast x_indices to match the spatial dimensions [1, packed_width]
|
||||
img_ids[..., 2] = x_indices[None, :]
|
||||
|
||||
# Expand to include batch dimension: [batch_size, (packed_height * packed_width), 3]
|
||||
img_ids = repeat(img_ids, "h w c -> b (h w) c", b=batch_size)
|
||||
|
||||
if device.type == "mps":
|
||||
img_ids = img_ids.to(orig_dtype)
|
||||
|
||||
return img_ids
|
||||
|
||||
|
||||
class KontextExtension:
|
||||
"""Applies FLUX Kontext (reference image) conditioning."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
kontext_field: FluxKontextConditioningField,
|
||||
context: InvocationContext,
|
||||
vae_field: VAEField,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
):
|
||||
"""
|
||||
Initializes the KontextExtension, pre-processing the reference image
|
||||
into latents and positional IDs.
|
||||
"""
|
||||
self._context = context
|
||||
self._device = device
|
||||
self._dtype = dtype
|
||||
self._vae_field = vae_field
|
||||
self.kontext_field = kontext_field
|
||||
|
||||
# Pre-process and cache the kontext latents and ids upon initialization.
|
||||
self.kontext_latents, self.kontext_ids = self._prepare_kontext()
|
||||
|
||||
def _prepare_kontext(self) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Encodes the reference image and prepares its latents and IDs."""
|
||||
image = self._context.images.get_pil(self.kontext_field.image.image_name)
|
||||
|
||||
# Reuse VAE encoding logic from FluxVaeEncodeInvocation
|
||||
vae_info = self._context.models.load(self._vae_field.vae)
|
||||
image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
|
||||
if image_tensor.dim() == 3:
|
||||
image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
|
||||
image_tensor = image_tensor.to(self._device)
|
||||
|
||||
kontext_latents_unpacked = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
|
||||
|
||||
# Extract tensor dimensions with descriptive names
|
||||
# Latent tensor shape: [batch_size, channels, latent_height, latent_width]
|
||||
batch_size, _, latent_height, latent_width = kontext_latents_unpacked.shape
|
||||
|
||||
# Pack the latents and generate IDs. The idx_offset distinguishes these
|
||||
# tokens from the main image's tokens, which have an index of 0.
|
||||
kontext_latents_packed = pack(kontext_latents_unpacked).to(self._device, self._dtype)
|
||||
kontext_ids = generate_img_ids_with_offset(
|
||||
latent_height=latent_height,
|
||||
latent_width=latent_width,
|
||||
batch_size=batch_size,
|
||||
device=self._device,
|
||||
dtype=self._dtype,
|
||||
idx_offset=1, # Distinguishes reference tokens from main image tokens
|
||||
)
|
||||
|
||||
return kontext_latents_packed, kontext_ids
|
||||
|
||||
def apply(
|
||||
self,
|
||||
img: torch.Tensor,
|
||||
img_ids: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Concatenates the pre-processed kontext data to the main image sequence."""
|
||||
# Ensure batch sizes match, repeating kontext data if necessary for batch operations.
|
||||
if img.shape[0] != self.kontext_latents.shape[0]:
|
||||
self.kontext_latents = self.kontext_latents.repeat(img.shape[0], 1, 1)
|
||||
self.kontext_ids = self.kontext_ids.repeat(img.shape[0], 1, 1)
|
||||
|
||||
# Concatenate along the sequence dimension (dim=1)
|
||||
combined_img = torch.cat([img, self.kontext_latents], dim=1)
|
||||
combined_img_ids = torch.cat([img_ids, self.kontext_ids], dim=1)
|
||||
|
||||
return combined_img, combined_img_ids
|
||||
@@ -7,7 +7,14 @@ from typing import Optional
|
||||
import accelerate
|
||||
import torch
|
||||
from safetensors.torch import load_file
|
||||
from transformers import AutoConfig, AutoModelForTextEncoding, CLIPTextModel, CLIPTokenizer, T5EncoderModel, T5Tokenizer
|
||||
from transformers import (
|
||||
AutoConfig,
|
||||
AutoModelForTextEncoding,
|
||||
CLIPTextModel,
|
||||
CLIPTokenizer,
|
||||
T5EncoderModel,
|
||||
T5TokenizerFast,
|
||||
)
|
||||
|
||||
from invokeai.app.services.config.config_default import get_config
|
||||
from invokeai.backend.flux.controlnet.instantx_controlnet_flux import InstantXControlNetFlux
|
||||
@@ -139,7 +146,7 @@ class BnbQuantizedLlmInt8bCheckpointModel(ModelLoader):
|
||||
)
|
||||
match submodel_type:
|
||||
case SubModelType.Tokenizer2 | SubModelType.Tokenizer3:
|
||||
return T5Tokenizer.from_pretrained(Path(config.path) / "tokenizer_2", max_length=512)
|
||||
return T5TokenizerFast.from_pretrained(Path(config.path) / "tokenizer_2", max_length=512)
|
||||
case SubModelType.TextEncoder2 | SubModelType.TextEncoder3:
|
||||
te2_model_path = Path(config.path) / "text_encoder_2"
|
||||
model_config = AutoConfig.from_pretrained(te2_model_path)
|
||||
@@ -183,7 +190,7 @@ class T5EncoderCheckpointModel(ModelLoader):
|
||||
|
||||
match submodel_type:
|
||||
case SubModelType.Tokenizer2 | SubModelType.Tokenizer3:
|
||||
return T5Tokenizer.from_pretrained(Path(config.path) / "tokenizer_2", max_length=512)
|
||||
return T5TokenizerFast.from_pretrained(Path(config.path) / "tokenizer_2", max_length=512)
|
||||
case SubModelType.TextEncoder2 | SubModelType.TextEncoder3:
|
||||
return T5EncoderModel.from_pretrained(
|
||||
Path(config.path) / "text_encoder_2", torch_dtype="auto", low_cpu_mem_usage=True
|
||||
|
||||
@@ -23,7 +23,7 @@ class StarterModel(StarterModelWithoutDependencies):
|
||||
dependencies: Optional[list[StarterModelWithoutDependencies]] = None
|
||||
|
||||
|
||||
class StarterModelBundles(BaseModel):
|
||||
class StarterModelBundle(BaseModel):
|
||||
name: str
|
||||
models: list[StarterModel]
|
||||
|
||||
@@ -109,7 +109,7 @@ flux_vae = StarterModel(
|
||||
|
||||
# region: Main
|
||||
flux_schnell_quantized = StarterModel(
|
||||
name="FLUX Schnell (Quantized)",
|
||||
name="FLUX.1 schnell (quantized)",
|
||||
base=BaseModelType.Flux,
|
||||
source="InvokeAI/flux_schnell::transformer/bnb_nf4/flux1-schnell-bnb_nf4.safetensors",
|
||||
description="FLUX schnell transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB",
|
||||
@@ -117,7 +117,7 @@ flux_schnell_quantized = StarterModel(
|
||||
dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder],
|
||||
)
|
||||
flux_dev_quantized = StarterModel(
|
||||
name="FLUX Dev (Quantized)",
|
||||
name="FLUX.1 dev (quantized)",
|
||||
base=BaseModelType.Flux,
|
||||
source="InvokeAI/flux_dev::transformer/bnb_nf4/flux1-dev-bnb_nf4.safetensors",
|
||||
description="FLUX dev transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB",
|
||||
@@ -125,7 +125,7 @@ flux_dev_quantized = StarterModel(
|
||||
dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder],
|
||||
)
|
||||
flux_schnell = StarterModel(
|
||||
name="FLUX Schnell",
|
||||
name="FLUX.1 schnell",
|
||||
base=BaseModelType.Flux,
|
||||
source="InvokeAI/flux_schnell::transformer/base/flux1-schnell.safetensors",
|
||||
description="FLUX schnell transformer in bfloat16. Total size with dependencies: ~33GB",
|
||||
@@ -133,13 +133,21 @@ flux_schnell = StarterModel(
|
||||
dependencies=[t5_base_encoder, flux_vae, clip_l_encoder],
|
||||
)
|
||||
flux_dev = StarterModel(
|
||||
name="FLUX Dev",
|
||||
name="FLUX.1 dev",
|
||||
base=BaseModelType.Flux,
|
||||
source="InvokeAI/flux_dev::transformer/base/flux1-dev.safetensors",
|
||||
description="FLUX dev transformer in bfloat16. Total size with dependencies: ~33GB",
|
||||
type=ModelType.Main,
|
||||
dependencies=[t5_base_encoder, flux_vae, clip_l_encoder],
|
||||
)
|
||||
flux_kontext = StarterModel(
|
||||
name="FLUX.1 Kontext dev",
|
||||
base=BaseModelType.Flux,
|
||||
source="black-forest-labs/FLUX.1-Kontext-dev::flux1-kontext-dev.safetensors",
|
||||
description="FLUX.1 Kontext dev transformer in bfloat16. Total size with dependencies: ~33GB",
|
||||
type=ModelType.Main,
|
||||
dependencies=[t5_base_encoder, flux_vae, clip_l_encoder],
|
||||
)
|
||||
sd35_medium = StarterModel(
|
||||
name="SD3.5 Medium",
|
||||
base=BaseModelType.StableDiffusion3,
|
||||
@@ -656,6 +664,7 @@ flux_fill = StarterModel(
|
||||
# List of starter models, displayed on the frontend.
|
||||
# The order/sort of this list is not changed by the frontend - set it how you want it here.
|
||||
STARTER_MODELS: list[StarterModel] = [
|
||||
flux_kontext,
|
||||
flux_schnell_quantized,
|
||||
flux_dev_quantized,
|
||||
flux_schnell,
|
||||
@@ -776,12 +785,13 @@ flux_bundle: list[StarterModel] = [
|
||||
flux_depth_control_lora,
|
||||
flux_redux,
|
||||
flux_fill,
|
||||
flux_kontext,
|
||||
]
|
||||
|
||||
STARTER_BUNDLES: dict[str, list[StarterModel]] = {
|
||||
BaseModelType.StableDiffusion1: sd1_bundle,
|
||||
BaseModelType.StableDiffusionXL: sdxl_bundle,
|
||||
BaseModelType.Flux: flux_bundle,
|
||||
STARTER_BUNDLES: dict[str, StarterModelBundle] = {
|
||||
BaseModelType.StableDiffusion1: StarterModelBundle(name="Stable Diffusion 1.5", models=sd1_bundle),
|
||||
BaseModelType.StableDiffusionXL: StarterModelBundle(name="SDXL", models=sdxl_bundle),
|
||||
BaseModelType.Flux: StarterModelBundle(name="FLUX.1 dev", models=flux_bundle),
|
||||
}
|
||||
|
||||
assert len(STARTER_MODELS) == len({m.source for m in STARTER_MODELS}), "Duplicate starter models"
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@reduxjs/toolkit": "2.8.2",
|
||||
"@roarr/browser-log-writer": "^1.3.0",
|
||||
"@xyflow/react": "^12.7.1",
|
||||
"ag-psd": "^28.2.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chakra-react-select": "^4.9.2",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
15
invokeai/frontend/web/pnpm-lock.yaml
generated
15
invokeai/frontend/web/pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ dependencies:
|
||||
'@xyflow/react':
|
||||
specifier: ^12.7.1
|
||||
version: 12.7.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
|
||||
ag-psd:
|
||||
specifier: ^28.2.1
|
||||
version: 28.2.1
|
||||
async-mutex:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
@@ -3655,6 +3658,13 @@ packages:
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/ag-psd@28.2.1:
|
||||
resolution: {integrity: sha512-dso+nogIiERMsukqwxDE6AXezYK7+t5CKhc4JG5S9neB8JhKMIIie5RdJwW0aaoNf03/wKD2kdk43TBDJskhxQ==}
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
pako: 2.1.0
|
||||
dev: false
|
||||
|
||||
/agent-base@7.1.3:
|
||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3919,7 +3929,6 @@ packages:
|
||||
|
||||
/base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
dev: true
|
||||
|
||||
/better-opn@3.0.2:
|
||||
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
|
||||
@@ -6666,6 +6675,10 @@ packages:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
dev: true
|
||||
|
||||
/pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
dev: false
|
||||
|
||||
/parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
@@ -225,7 +225,16 @@
|
||||
"prompt": {
|
||||
"addPromptTrigger": "Add Prompt Trigger",
|
||||
"compatibleEmbeddings": "Compatible Embeddings",
|
||||
"noMatchingTriggers": "No matching triggers"
|
||||
"noMatchingTriggers": "No matching triggers",
|
||||
"generateFromImage": "Generate prompt from image",
|
||||
"expandCurrentPrompt": "Expand Current Prompt",
|
||||
"uploadImageForPromptGeneration": "Upload Image for Prompt Generation",
|
||||
"expandingPrompt": "Expanding prompt...",
|
||||
"resultTitle": "Prompt Expansion Complete",
|
||||
"resultSubtitle": "Choose how to handle the expanded prompt:",
|
||||
"replace": "Replace",
|
||||
"insert": "Insert",
|
||||
"discard": "Discard"
|
||||
},
|
||||
"queue": {
|
||||
"queue": "Queue",
|
||||
@@ -335,14 +344,14 @@
|
||||
"images": "Images",
|
||||
"assets": "Assets",
|
||||
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
|
||||
"assetsTab": "Files you’ve uploaded for use in your projects.",
|
||||
"assetsTab": "Files you've uploaded for use in your projects.",
|
||||
"autoAssignBoardOnClick": "Auto-Assign Board on Click",
|
||||
"autoSwitchNewImages": "Auto-Switch to New Images",
|
||||
"boardsSettings": "Boards Settings",
|
||||
"copy": "Copy",
|
||||
"currentlyInUse": "This image is currently in use in the following features:",
|
||||
"drop": "Drop",
|
||||
"dropOrUpload": "$t(gallery.drop) or Upload",
|
||||
"dropOrUpload": "Drop or Upload",
|
||||
"dropToUpload": "$t(gallery.drop) to Upload",
|
||||
"deleteImage_one": "Delete Image",
|
||||
"deleteImage_other": "Delete {{count}} Images",
|
||||
@@ -357,7 +366,7 @@
|
||||
"gallerySettings": "Gallery Settings",
|
||||
"go": "Go",
|
||||
"image": "image",
|
||||
"imagesTab": "Images you’ve created and saved within Invoke.",
|
||||
"imagesTab": "Images you've created and saved within Invoke.",
|
||||
"imagesSettings": "Gallery Images Settings",
|
||||
"jump": "Jump",
|
||||
"loading": "Loading",
|
||||
@@ -396,7 +405,8 @@
|
||||
"compareHelp4": "Press <Kbd>Z</Kbd> or <Kbd>Esc</Kbd> to exit.",
|
||||
"openViewer": "Open Viewer",
|
||||
"closeViewer": "Close Viewer",
|
||||
"move": "Move"
|
||||
"move": "Move",
|
||||
"useForPromptGeneration": "Use for Prompt Generation"
|
||||
},
|
||||
"hotkeys": {
|
||||
"hotkeys": "Hotkeys",
|
||||
@@ -579,6 +589,16 @@
|
||||
"cancelTransform": {
|
||||
"title": "Cancel Transform",
|
||||
"desc": "Cancel the pending transform."
|
||||
},
|
||||
"settings": {
|
||||
"behavior": "Behavior",
|
||||
"display": "Display",
|
||||
"grid": "Grid",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"toggleNonRasterLayers": {
|
||||
"title": "Toggle Non-Raster Layers",
|
||||
"desc": "Show or hide all non-raster layer categories (Control Layers, Inpaint Masks, Regional Guidance)."
|
||||
}
|
||||
},
|
||||
"workflows": {
|
||||
@@ -763,7 +783,7 @@
|
||||
"convertToDiffusers": "Convert To Diffusers",
|
||||
"convertToDiffusersHelpText1": "This model will be converted to the 🧨 Diffusers format.",
|
||||
"convertToDiffusersHelpText2": "This process will replace your Model Manager entry with the Diffusers version of the same model.",
|
||||
"convertToDiffusersHelpText3": "Your checkpoint file on disk WILL be deleted if it is in InvokeAI root folder. If it is in a custom location, then it WILL NOT be deleted.",
|
||||
"convertToDiffusersHelpText3": "Your checkpoint file on disk WILL be deleted if it is in the InvokeAI root folder. If it is in a custom location, then it WILL NOT be deleted.",
|
||||
"convertToDiffusersHelpText4": "This is a one time process only. It might take around 30s-60s depending on the specifications of your computer.",
|
||||
"convertToDiffusersHelpText5": "Please make sure you have enough disk space. Models generally vary between 2GB-7GB in size.",
|
||||
"convertToDiffusersHelpText6": "Do you wish to convert this model?",
|
||||
@@ -806,7 +826,11 @@
|
||||
"urlUnauthorizedErrorMessage": "You may need to configure an API token to access this model.",
|
||||
"urlUnauthorizedErrorMessage2": "Learn how here.",
|
||||
"imageEncoderModelId": "Image Encoder Model ID",
|
||||
"includesNModels": "Includes {{n}} models and their dependencies",
|
||||
"installedModelsCount": "{{installed}} of {{total}} models installed.",
|
||||
"includesNModels": "Includes {{n}} models and their dependencies.",
|
||||
"allNModelsInstalled": "All {{count}} models installed",
|
||||
"nToInstall": "{{count}} to install",
|
||||
"nAlreadyInstalled": "{{count}} already installed",
|
||||
"installQueue": "Install Queue",
|
||||
"inplaceInstall": "In-place install",
|
||||
"inplaceInstallDesc": "Install models without copying the files. When using the model, it will be loaded from its this location. If disabled, the model file(s) will be copied into the Invoke-managed models directory during installation.",
|
||||
@@ -869,6 +893,25 @@
|
||||
"starterBundleHelpText": "Easily install all models needed to get started with a base model, including a main model, controlnets, IP adapters, and more. Selecting a bundle will skip any models that you already have installed.",
|
||||
"starterModels": "Starter Models",
|
||||
"starterModelsInModelManager": "Starter Models can be found in Model Manager",
|
||||
"bundleAlreadyInstalled": "Bundle already installed",
|
||||
"bundleAlreadyInstalledDesc": "All models in the {{bundleName}} bundle are already installed.",
|
||||
"launchpadTab": "Launchpad",
|
||||
"launchpad": {
|
||||
"welcome": "Welcome to Model Management",
|
||||
"description": "Invoke requires models to be installed to utilize most features of the platform. Choose from manual installation options or explore curated starter models.",
|
||||
"manualInstall": "Manual Installation",
|
||||
"urlDescription": "Install models from a URL or local file path. Perfect for specific models you want to add.",
|
||||
"huggingFaceDescription": "Browse and install models directly from HuggingFace repositories.",
|
||||
"scanFolderDescription": "Scan a local folder to automatically detect and install models.",
|
||||
"recommendedModels": "Recommended Models",
|
||||
"exploreStarter": "Or browse all available starter models",
|
||||
"quickStart": "Quick Start Bundles",
|
||||
"bundleDescription": "Each bundle includes essential models for each model family and curated base models to get started.",
|
||||
"browseAll": "Or browse all available models:",
|
||||
"stableDiffusion15": "Stable Diffusion 1.5",
|
||||
"sdxl": "SDXL",
|
||||
"fluxDev": "FLUX.1 dev"
|
||||
},
|
||||
"controlLora": "Control LoRA",
|
||||
"llavaOnevision": "LLaVA OneVision",
|
||||
"syncModels": "Sync Models",
|
||||
@@ -905,7 +948,8 @@
|
||||
"selectModel": "Select a Model",
|
||||
"noLoRAsInstalled": "No LoRAs installed",
|
||||
"noRefinerModelsInstalled": "No SDXL Refiner models installed",
|
||||
"defaultVAE": "Default VAE"
|
||||
"defaultVAE": "Default VAE",
|
||||
"noCompatibleLoRAs": "No Compatible LoRAs"
|
||||
},
|
||||
"nodes": {
|
||||
"arithmeticSequence": "Arithmetic Sequence",
|
||||
@@ -1155,7 +1199,9 @@
|
||||
"canvasIsSelectingObject": "Canvas is busy (selecting object)",
|
||||
"noPrompts": "No prompts generated",
|
||||
"noNodesInGraph": "No nodes in graph",
|
||||
"systemDisconnected": "System disconnected"
|
||||
"systemDisconnected": "System disconnected",
|
||||
"promptExpansionPending": "Prompt expansion in progress",
|
||||
"promptExpansionResultPending": "Please accept or discard your prompt expansion result"
|
||||
},
|
||||
"maskBlur": "Mask Blur",
|
||||
"negativePromptPlaceholder": "Negative Prompt",
|
||||
@@ -1313,6 +1359,21 @@
|
||||
"problemCopyingLayer": "Unable to Copy Layer",
|
||||
"problemSavingLayer": "Unable to Save Layer",
|
||||
"problemDownloadingImage": "Unable to Download Image",
|
||||
"noRasterLayers": "No Raster Layers Found",
|
||||
"noRasterLayersDesc": "Create at least one raster layer to export to PSD",
|
||||
"noActiveRasterLayers": "No Active Raster Layers",
|
||||
"noActiveRasterLayersDesc": "Enable at least one raster layer to export to PSD",
|
||||
"noVisibleRasterLayers": "No Visible Raster Layers",
|
||||
"noVisibleRasterLayersDesc": "Enable at least one raster layer to export to PSD",
|
||||
"invalidCanvasDimensions": "Invalid Canvas Dimensions",
|
||||
"canvasTooLarge": "Canvas Too Large",
|
||||
"canvasTooLargeDesc": "Canvas dimensions exceed the maximum allowed size for PSD export. Reduce the total width and height of the canvas of the canvas and try again.",
|
||||
"failedToProcessLayers": "Failed to Process Layers",
|
||||
"psdExportSuccess": "PSD Export Complete",
|
||||
"psdExportSuccessDesc": "Successfully exported {{count}} layers to PSD file",
|
||||
"problemExportingPSD": "Problem Exporting PSD",
|
||||
"canvasManagerNotAvailable": "Canvas Manager Not Available",
|
||||
"noValidLayerAdapters": "No Valid Layer Adapters Found",
|
||||
"pasteSuccess": "Pasted to {{destination}}",
|
||||
"pasteFailed": "Paste Failed",
|
||||
"prunedQueue": "Pruned Queue",
|
||||
@@ -1341,7 +1402,12 @@
|
||||
"fluxKontextIncompatibleGenerationMode": "Flux Kontext supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.",
|
||||
"problemUnpublishingWorkflow": "Problem Unpublishing Workflow",
|
||||
"problemUnpublishingWorkflowDescription": "There was a problem unpublishing the workflow. Please try again.",
|
||||
"workflowUnpublished": "Workflow Unpublished"
|
||||
"workflowUnpublished": "Workflow Unpublished",
|
||||
"sentToCanvas": "Sent to Canvas",
|
||||
"sentToUpscale": "Sent to Upscale",
|
||||
"promptGenerationStarted": "Prompt generation started",
|
||||
"uploadAndPromptGenerationFailed": "Failed to upload image and generate prompt",
|
||||
"promptExpansionFailed": "Prompt expansion failed"
|
||||
},
|
||||
"popovers": {
|
||||
"clipSkip": {
|
||||
@@ -1864,6 +1930,7 @@
|
||||
"saveCanvasToGallery": "Save Canvas to Gallery",
|
||||
"saveBboxToGallery": "Save Bbox to Gallery",
|
||||
"saveLayerToAssets": "Save Layer to Assets",
|
||||
"exportCanvasToPSD": "Export Canvas to PSD",
|
||||
"cropLayerToBbox": "Crop Layer to Bbox",
|
||||
"savedToGalleryOk": "Saved to Gallery",
|
||||
"savedToGalleryError": "Error saving to gallery",
|
||||
@@ -1889,6 +1956,7 @@
|
||||
"mergingLayers": "Merging layers",
|
||||
"clearHistory": "Clear History",
|
||||
"bboxOverlay": "Show Bbox Overlay",
|
||||
"ruleOfThirds": "Show Rule of Thirds",
|
||||
"newSession": "New Session",
|
||||
"clearCaches": "Clear Caches",
|
||||
"recalculateRects": "Recalculate Rects",
|
||||
@@ -1994,6 +2062,8 @@
|
||||
"disableTransparencyEffect": "Disable Transparency Effect",
|
||||
"hidingType": "Hiding {{type}}",
|
||||
"showingType": "Showing {{type}}",
|
||||
"showNonRasterLayers": "Show Non-Raster Layers (Shift+H)",
|
||||
"hideNonRasterLayers": "Hide Non-Raster Layers (Shift+H)",
|
||||
"dynamicGrid": "Dynamic Grid",
|
||||
"logDebugInfo": "Log Debug Info",
|
||||
"locked": "Locked",
|
||||
@@ -2371,7 +2441,8 @@
|
||||
"uploadImage": "Upload Image",
|
||||
"useForTemplate": "Use For Prompt Template",
|
||||
"viewList": "View Template List",
|
||||
"viewModeTooltip": "This is how your prompt will look with your currently selected template. To edit your prompt, click anywhere in the text box."
|
||||
"viewModeTooltip": "This is how your prompt will look with your currently selected template. To edit your prompt, click anywhere in the text box.",
|
||||
"togglePromptPreviews": "Toggle Prompt Previews"
|
||||
},
|
||||
"upsell": {
|
||||
"inviteTeammates": "Invite Teammates",
|
||||
@@ -2391,6 +2462,55 @@
|
||||
"upscaling": "Upscaling",
|
||||
"upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)",
|
||||
"gallery": "Gallery"
|
||||
},
|
||||
"launchpad": {
|
||||
"workflowsTitle": "Go deep with Workflows.",
|
||||
"upscalingTitle": "Upscale and add detail.",
|
||||
"canvasTitle": "Edit and refine on Canvas.",
|
||||
"generateTitle": "Generate images from text prompts.",
|
||||
"modelGuideText": "Want to learn what prompts work best for each model?",
|
||||
"modelGuideLink": "Check out our Model Guide.",
|
||||
"workflows": {
|
||||
"description": "Workflows are reusable templates that automate image generation tasks, allowing you to quickly perform complex operations and get consistent results.",
|
||||
"learnMoreLink": "Learn more about creating workflows",
|
||||
"browseTemplates": {
|
||||
"title": "Browse Workflow Templates",
|
||||
"description": "Choose from pre-built workflows for common tasks"
|
||||
},
|
||||
"createNew": {
|
||||
"title": "Create a new Workflow",
|
||||
"description": "Start a new workflow from scratch"
|
||||
},
|
||||
"loadFromFile": {
|
||||
"title": "Load workflow from file",
|
||||
"description": "Upload a workflow to start with an existing setup"
|
||||
}
|
||||
},
|
||||
"upscaling": {
|
||||
"uploadImage": {
|
||||
"title": "Upload Image to Upscale",
|
||||
"description": "Click or drag an image to upscale (JPG, PNG, WebP up to 100MB)"
|
||||
},
|
||||
"replaceImage": {
|
||||
"title": "Replace Current Image",
|
||||
"description": "Click or drag a new image to replace the current one"
|
||||
},
|
||||
"imageReady": {
|
||||
"title": "Image Ready",
|
||||
"description": "Press Invoke to begin upscaling"
|
||||
},
|
||||
"readyToUpscale": {
|
||||
"title": "Ready to upscale!",
|
||||
"description": "Configure your settings below, then click the Invoke button to begin upscaling your image."
|
||||
},
|
||||
"upscaleModel": "Upscale Model",
|
||||
"model": "Model",
|
||||
"scale": "Scale",
|
||||
"helpText": {
|
||||
"promptAdvice": "When upscaling, use a prompt that describes the medium and style. Avoid describing specific content details in the image.",
|
||||
"styleAdvice": "Upscaling works best with the general style of your image."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
|
||||
@@ -10,13 +10,13 @@ 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 { size } from 'es-toolkit/compat';
|
||||
import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher';
|
||||
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
|
||||
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
|
||||
import { useReadinessWatcher } from 'features/queue/store/readiness';
|
||||
import { configChanged } from 'features/system/store/configSlice';
|
||||
import { selectLanguage } from 'features/system/store/systemSelectors';
|
||||
import { useNavigationApi } from 'features/ui/layouts/use-navigation-api';
|
||||
import i18n from 'i18n';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
|
||||
@@ -43,6 +43,7 @@ export const GlobalHookIsolator = memo(
|
||||
useGetOpenAPISchemaQuery();
|
||||
useSyncLoggingConfig();
|
||||
useCloseChakraTooltipsOnDragFix();
|
||||
useNavigationApi();
|
||||
|
||||
// Persistent subscription to the queue counts query - canvas relies on this to know if there are pending
|
||||
// and/or in progress canvas sessions.
|
||||
@@ -53,10 +54,8 @@ export const GlobalHookIsolator = memo(
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (size(config)) {
|
||||
logger.info({ config }, 'Received config');
|
||||
dispatch(configChanged(config));
|
||||
}
|
||||
logger.info({ config }, 'Received config');
|
||||
dispatch(configChanged(config));
|
||||
}, [dispatch, config, logger]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddlewar
|
||||
import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted';
|
||||
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 { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
|
||||
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
|
||||
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
|
||||
@@ -20,7 +19,6 @@ import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMi
|
||||
import type { AppDispatch, RootState } from 'app/store/store';
|
||||
|
||||
import { addArchivedOrDeletedBoardListener } from './listeners/addArchivedOrDeletedBoardListener';
|
||||
import { addEnqueueRequestedUpscale } from './listeners/enqueueRequestedUpscale';
|
||||
|
||||
export const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
@@ -43,8 +41,6 @@ addImageUploadedFulfilledListener(startAppListening);
|
||||
addDeleteBoardAndImagesFulfilledListener(startAppListening);
|
||||
|
||||
// User Invoked
|
||||
addEnqueueRequestedLinear(startAppListening);
|
||||
addEnqueueRequestedUpscale(startAppListening);
|
||||
addAnyEnqueuedListener(startAppListening);
|
||||
addBatchEnqueuedListener(startAppListening);
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { AlertStatus } from '@invoke-ai/ui-library';
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
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';
|
||||
import { buildCogView4Graph } from 'features/nodes/util/graph/generation/buildCogView4Graph';
|
||||
import { buildFLUXGraph } from 'features/nodes/util/graph/generation/buildFLUXGraph';
|
||||
import { buildFluxKontextGraph } from 'features/nodes/util/graph/generation/buildFluxKontextGraph';
|
||||
import { buildImagen3Graph } from 'features/nodes/util/graph/generation/buildImagen3Graph';
|
||||
import { buildImagen4Graph } from 'features/nodes/util/graph/generation/buildImagen4Graph';
|
||||
import { buildSD1Graph } from 'features/nodes/util/graph/generation/buildSD1Graph';
|
||||
import { buildSD3Graph } from 'features/nodes/util/graph/generation/buildSD3Graph';
|
||||
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';
|
||||
|
||||
const log = logger('generation');
|
||||
|
||||
export const enqueueRequestedCanvas = createAction<{ prepend: boolean }>('app/enqueueRequestedCanvas');
|
||||
|
||||
export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
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');
|
||||
|
||||
const model = state.params.model;
|
||||
assert(model, 'No model found in state');
|
||||
const base = model.base;
|
||||
|
||||
const buildGraphResult = await withResultAsync(async () => {
|
||||
switch (base) {
|
||||
case 'sdxl':
|
||||
return await buildSDXLGraph(state, manager);
|
||||
case 'sd-1':
|
||||
case `sd-2`:
|
||||
return await buildSD1Graph(state, manager);
|
||||
case `sd-3`:
|
||||
return await buildSD3Graph(state, manager);
|
||||
case `flux`:
|
||||
return await buildFLUXGraph(state, manager);
|
||||
case 'cogview4':
|
||||
return await buildCogView4Graph(state, manager);
|
||||
case 'imagen3':
|
||||
return await buildImagen3Graph(state, manager);
|
||||
case 'imagen4':
|
||||
return await buildImagen4Graph(state, manager);
|
||||
case 'chatgpt-4o':
|
||||
return await buildChatGPT4oGraph(state, manager);
|
||||
case 'flux-kontext':
|
||||
return await buildFluxKontextGraph(state, manager);
|
||||
default:
|
||||
assert(false, `No graph builders for base ${base}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (buildGraphResult.isErr()) {
|
||||
let title = 'Failed to build graph';
|
||||
let status: AlertStatus = 'error';
|
||||
let description: string | null = null;
|
||||
if (buildGraphResult.error instanceof AssertionError) {
|
||||
description = extractMessageFromAssertionError(buildGraphResult.error);
|
||||
} else if (buildGraphResult.error instanceof UnsupportedGenerationModeError) {
|
||||
title = 'Unsupported generation mode';
|
||||
description = buildGraphResult.error.message;
|
||||
status = 'warning';
|
||||
}
|
||||
const error = serializeError(buildGraphResult.error);
|
||||
log.error({ error }, 'Failed to build graph');
|
||||
toast({
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { g, seedFieldIdentifier, positivePromptFieldIdentifier } = buildGraphResult.value;
|
||||
|
||||
const prepareBatchResult = withResult(() =>
|
||||
prepareLinearUIBatch({
|
||||
state,
|
||||
g,
|
||||
prepend,
|
||||
seedFieldIdentifier,
|
||||
positivePromptFieldIdentifier,
|
||||
origin: tab,
|
||||
destination,
|
||||
})
|
||||
);
|
||||
|
||||
if (prepareBatchResult.isErr()) {
|
||||
log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch');
|
||||
return;
|
||||
}
|
||||
|
||||
const req = dispatch(
|
||||
queueApi.endpoints.enqueueBatch.initiate(prepareBatchResult.value, enqueueMutationFixedCacheKeyOptions)
|
||||
);
|
||||
|
||||
try {
|
||||
await req.unwrap();
|
||||
log.debug(parseify({ batchConfig: prepareBatchResult.value }), 'Enqueued batch');
|
||||
} catch (error) {
|
||||
log.error({ error: serializeError(error as Error) }, 'Failed to enqueue batch');
|
||||
} finally {
|
||||
req.reset();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
|
||||
import { buildMultidiffusionUpscaleGraph } from 'features/nodes/util/graph/buildMultidiffusionUpscaleGraph';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
|
||||
const log = logger('generation');
|
||||
|
||||
export const enqueueRequestedUpscaling = createAction<{ prepend: boolean }>('app/enqueueRequestedUpscaling');
|
||||
|
||||
export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
actionCreator: enqueueRequestedUpscaling,
|
||||
effect: async (action, { getState, dispatch }) => {
|
||||
const state = getState();
|
||||
const { prepend } = action.payload;
|
||||
|
||||
const { g, seedFieldIdentifier, positivePromptFieldIdentifier } = await buildMultidiffusionUpscaleGraph(state);
|
||||
|
||||
const batchConfig = prepareLinearUIBatch({
|
||||
state,
|
||||
g,
|
||||
prepend,
|
||||
seedFieldIdentifier,
|
||||
positivePromptFieldIdentifier,
|
||||
origin: 'upscaling',
|
||||
destination: 'gallery',
|
||||
});
|
||||
|
||||
const req = dispatch(queueApi.endpoints.enqueueBatch.initiate(batchConfig, enqueueMutationFixedCacheKeyOptions));
|
||||
try {
|
||||
await req.unwrap();
|
||||
log.debug(parseify({ batchConfig }), 'Enqueued batch');
|
||||
} catch (error) {
|
||||
log.error({ error: serializeError(error as Error) }, 'Failed to enqueue batch');
|
||||
} finally {
|
||||
req.reset();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { AppStore } from 'app/store/store';
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
@@ -32,11 +31,3 @@ export const getStore = () => {
|
||||
}
|
||||
return store;
|
||||
};
|
||||
|
||||
export const useAppStore = () => {
|
||||
const store = useStore($store);
|
||||
if (!store) {
|
||||
throw new ReduxStoreNotInitialized();
|
||||
}
|
||||
return store;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AppThunkDispatch, RootState } from 'app/store/store';
|
||||
import type { AppStore, AppThunkDispatch, RootState } from 'app/store/store';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = () => useDispatch<AppThunkDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
export const useAppStore = () => useStore<RootState>();
|
||||
export const useAppStore = () => useStore.withTypes<AppStore>()();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Selector } from '@reduxjs/toolkit';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AppFeature =
|
||||
| 'githubLink'
|
||||
| 'discordLink'
|
||||
| 'bugLink'
|
||||
| 'aboutModal'
|
||||
| 'localization'
|
||||
| 'consoleLogging'
|
||||
| 'dynamicPrompting'
|
||||
@@ -29,7 +30,8 @@ export type AppFeature =
|
||||
| 'hfToken'
|
||||
| 'retryQueueItem'
|
||||
| 'cancelAndClearAll'
|
||||
| 'chatGPT4oHigh';
|
||||
| 'chatGPT4oHigh'
|
||||
| 'modelRelationships';
|
||||
/**
|
||||
* A disable-able Stable Diffusion feature
|
||||
*/
|
||||
@@ -76,6 +78,7 @@ export type AppConfig = {
|
||||
allowPrivateStylePresets: boolean;
|
||||
allowClientSideUpload: boolean;
|
||||
allowPublishWorkflows: boolean;
|
||||
allowPromptExpansion: boolean;
|
||||
disabledTabs: TabName[];
|
||||
disabledFeatures: AppFeature[];
|
||||
disabledSDFeatures: SDFeature[];
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Box, type BoxProps, type SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { type FocusRegionName, useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { selectSystemShouldEnableHighlightFocusedRegions } from 'features/system/store/systemSlice';
|
||||
import { memo, useMemo, useRef } from 'react';
|
||||
|
||||
interface FocusRegionWrapperProps extends BoxProps {
|
||||
region: FocusRegionName;
|
||||
focusOnMount?: boolean;
|
||||
}
|
||||
|
||||
const FOCUS_REGION_STYLES: SystemStyleObject = {
|
||||
position: 'relative',
|
||||
'&[data-highlighted="true"]::after': {
|
||||
borderColor: 'blue.700',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
borderRadius: 'base',
|
||||
border: '2px solid',
|
||||
borderColor: 'transparent',
|
||||
pointerEvents: 'none',
|
||||
transition: 'border-color 0.1s ease-in-out',
|
||||
},
|
||||
};
|
||||
|
||||
export const FocusRegionWrapper = memo(
|
||||
({ region, focusOnMount = false, sx, children, ...boxProps }: FocusRegionWrapperProps) => {
|
||||
const shouldHighlightFocusedRegions = useAppSelector(selectSystemShouldEnableHighlightFocusedRegions);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const options = useMemo(() => ({ focusOnMount }), [focusOnMount]);
|
||||
|
||||
useFocusRegion(region, ref, options);
|
||||
const isFocused = useIsRegionFocused(region);
|
||||
const isHighlighted = isFocused && shouldHighlightFocusedRegions;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
sx={useMemo(() => ({ ...FOCUS_REGION_STYLES, ...sx }), [sx])}
|
||||
data-highlighted={isHighlighted}
|
||||
{...boxProps}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FocusRegionWrapper.displayName = 'FocusRegionWrapper';
|
||||
@@ -91,6 +91,10 @@ const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGro
|
||||
return uniqueGroupKey in optionOrGroup && optionOrGroup[uniqueGroupKey] === true;
|
||||
};
|
||||
|
||||
export const isOption = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is T => {
|
||||
return !(uniqueGroupKey in optionOrGroup);
|
||||
};
|
||||
|
||||
const DefaultOptionComponent = typedMemo(<T extends object>({ option }: { option: T }) => {
|
||||
const { getOptionId } = usePickerContext();
|
||||
return <Text fontWeight="bold">{getOptionId(option)}</Text>;
|
||||
@@ -198,6 +202,10 @@ type PickerProps<T extends object> = {
|
||||
* Whether the picker should be searchable. If true, renders a search input.
|
||||
*/
|
||||
searchable?: boolean;
|
||||
/**
|
||||
* Initial state for group toggles. If provided, groups will start with these states instead of all being disabled.
|
||||
*/
|
||||
initialGroupStates?: GroupStatusMap;
|
||||
};
|
||||
|
||||
export type PickerContextState<T extends object> = {
|
||||
@@ -310,9 +318,9 @@ const flattenOptions = <T extends object>(options: OptionOrGroup<T>[]): T[] => {
|
||||
return flattened;
|
||||
};
|
||||
|
||||
type GroupStatusMap = Record<string, boolean>;
|
||||
export type GroupStatusMap = Record<string, boolean>;
|
||||
|
||||
const useTogglableGroups = <T extends object>(options: OptionOrGroup<T>[]) => {
|
||||
const useTogglableGroups = <T extends object>(options: OptionOrGroup<T>[], initialGroupStates?: GroupStatusMap) => {
|
||||
const groupsWithOptions = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
for (const optionOrGroup of options) {
|
||||
@@ -332,14 +340,16 @@ const useTogglableGroups = <T extends object>(options: OptionOrGroup<T>[]) => {
|
||||
const groupStatusMap = $groupStatusMap.get();
|
||||
const newMap: GroupStatusMap = {};
|
||||
for (const id of groupsWithOptions) {
|
||||
if (newMap[id] === undefined) {
|
||||
newMap[id] = false;
|
||||
if (initialGroupStates && initialGroupStates[id] !== undefined) {
|
||||
newMap[id] = initialGroupStates[id];
|
||||
} else if (groupStatusMap[id] !== undefined) {
|
||||
newMap[id] = groupStatusMap[id];
|
||||
} else {
|
||||
newMap[id] = false;
|
||||
}
|
||||
}
|
||||
$groupStatusMap.set(newMap);
|
||||
}, [groupsWithOptions, $groupStatusMap]);
|
||||
}, [groupsWithOptions, $groupStatusMap, initialGroupStates]);
|
||||
|
||||
const toggleGroup = useCallback(
|
||||
(idToToggle: string) => {
|
||||
@@ -511,10 +521,14 @@ export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
|
||||
OptionComponent = DefaultOptionComponent,
|
||||
NextToSearchBar,
|
||||
searchable,
|
||||
initialGroupStates,
|
||||
} = props;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { $groupStatusMap, $areAllGroupsDisabled, toggleGroup } = useTogglableGroups(optionsOrGroups);
|
||||
const { $groupStatusMap, $areAllGroupsDisabled, toggleGroup } = useTogglableGroups(
|
||||
optionsOrGroups,
|
||||
initialGroupStates
|
||||
);
|
||||
const $activeOptionId = useAtom(getFirstOptionId(optionsOrGroups, getOptionId));
|
||||
const $compactView = useAtom(true);
|
||||
const $optionsOrGroups = useAtom(optionsOrGroups);
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import {
|
||||
useNewCanvasSession,
|
||||
useNewGallerySession,
|
||||
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
|
||||
import { allEntitiesDeleted } from 'features/controlLayers/store/canvasSlice';
|
||||
import { paramsReset } from 'features/controlLayers/store/paramsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowsCounterClockwiseBold, PiFilePlusBold } from 'react-icons/pi';
|
||||
import { PiArrowsCounterClockwiseBold } from 'react-icons/pi';
|
||||
|
||||
export const SessionMenuItems = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { newGallerySessionWithDialog } = useNewGallerySession();
|
||||
const { newCanvasSessionWithDialog } = useNewCanvasSession();
|
||||
|
||||
const resetCanvasLayers = useCallback(() => {
|
||||
dispatch(allEntitiesDeleted());
|
||||
}, [dispatch]);
|
||||
@@ -23,12 +18,6 @@ export const SessionMenuItems = memo(() => {
|
||||
}, [dispatch]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<PiFilePlusBold />} onClick={newGallerySessionWithDialog}>
|
||||
{t('controlLayers.newGallerySession')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiFilePlusBold />} onClick={newCanvasSessionWithDialog}>
|
||||
{t('controlLayers.newCanvasSession')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiArrowsCounterClockwiseBold />} onClick={resetCanvasLayers}>
|
||||
{t('controlLayers.resetCanvasLayers')}
|
||||
</MenuItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { atom, computed } from 'nanostores';
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { objectKeys } from 'tsafe';
|
||||
import z from 'zod/v4';
|
||||
|
||||
/**
|
||||
* We need to manage focus regions to conditionally enable hotkeys:
|
||||
@@ -30,23 +31,34 @@ const log = logger('system');
|
||||
/**
|
||||
* The names of the focus regions.
|
||||
*/
|
||||
export type FocusRegionName = 'gallery' | 'layers' | 'canvas' | 'workflows' | 'viewer';
|
||||
const zFocusRegionName = z.enum([
|
||||
'launchpad',
|
||||
'viewer',
|
||||
'gallery',
|
||||
'boards',
|
||||
'layers',
|
||||
'canvas',
|
||||
'workflows',
|
||||
'progress',
|
||||
'settings',
|
||||
]);
|
||||
export type FocusRegionName = z.infer<typeof zFocusRegionName>;
|
||||
|
||||
/**
|
||||
* A map of focus regions to the elements that are part of that region.
|
||||
*/
|
||||
const REGION_TARGETS: Record<FocusRegionName, Set<HTMLElement>> = {
|
||||
gallery: new Set<HTMLElement>(),
|
||||
layers: new Set<HTMLElement>(),
|
||||
canvas: new Set<HTMLElement>(),
|
||||
workflows: new Set<HTMLElement>(),
|
||||
viewer: new Set<HTMLElement>(),
|
||||
} as const;
|
||||
const REGION_TARGETS: Record<FocusRegionName, Set<HTMLElement>> = zFocusRegionName.options.values().reduce(
|
||||
(acc, region) => {
|
||||
acc[region] = new Set<HTMLElement>();
|
||||
return acc;
|
||||
},
|
||||
{} as Record<FocusRegionName, Set<HTMLElement>>
|
||||
);
|
||||
|
||||
/**
|
||||
* The currently-focused region or `null` if no region is focused.
|
||||
*/
|
||||
export const $focusedRegion = atom<FocusRegionName | null>(null);
|
||||
const $focusedRegion = atom<FocusRegionName | null>(null);
|
||||
|
||||
/**
|
||||
* A map of focus regions to atoms that indicate if that region is focused.
|
||||
@@ -62,11 +74,13 @@ const FOCUS_REGIONS = objectKeys(REGION_TARGETS).reduce(
|
||||
/**
|
||||
* Sets the focused region, logging a trace level message.
|
||||
*/
|
||||
const setFocus = (region: FocusRegionName | null) => {
|
||||
export const setFocusedRegion = (region: FocusRegionName | null) => {
|
||||
$focusedRegion.set(region);
|
||||
log.trace(`Focus changed: ${region}`);
|
||||
};
|
||||
|
||||
export const getFocusedRegion = () => $focusedRegion.get();
|
||||
|
||||
type UseFocusRegionOptions = {
|
||||
focusOnMount?: boolean;
|
||||
};
|
||||
@@ -99,14 +113,14 @@ export const useFocusRegion = (
|
||||
REGION_TARGETS[region].add(element);
|
||||
|
||||
if (focusOnMount) {
|
||||
setFocus(region);
|
||||
setFocusedRegion(region);
|
||||
}
|
||||
|
||||
return () => {
|
||||
REGION_TARGETS[region].delete(element);
|
||||
|
||||
if (REGION_TARGETS[region].size === 0 && $focusedRegion.get() === region) {
|
||||
setFocus(null);
|
||||
setFocusedRegion(null);
|
||||
}
|
||||
};
|
||||
}, [options, ref, region]);
|
||||
@@ -163,7 +177,7 @@ const onFocus = (_: FocusEvent) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocus(focusedRegion);
|
||||
setFocusedRegion(focusedRegion);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
|
||||
import { selectSelection } from 'features/gallery/store/gallerySelectors';
|
||||
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
|
||||
import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem';
|
||||
import { useInvoke } from 'features/queue/hooks/useInvoke';
|
||||
@@ -6,8 +8,10 @@ import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/us
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { setActiveTab } from 'features/ui/store/uiSlice';
|
||||
|
||||
import { getFocusedRegion } from './focus';
|
||||
|
||||
export const useGlobalHotkeys = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { dispatch, getState } = useAppStore();
|
||||
const isModelManagerEnabled = useFeatureStatus('modelManager');
|
||||
const queue = useInvoke();
|
||||
|
||||
@@ -118,19 +122,21 @@ export const useGlobalHotkeys = () => {
|
||||
dependencies: [dispatch, isModelManagerEnabled],
|
||||
});
|
||||
|
||||
// TODO: implement delete - needs to handle gallery focus, which has changed w/ dockview
|
||||
// useRegisteredHotkeys({
|
||||
// id: 'deleteSelection',
|
||||
// category: 'gallery',
|
||||
// callback: () => {
|
||||
// if (!selection.length) {
|
||||
// return;
|
||||
// }
|
||||
// deleteImageModal.delete(selection);
|
||||
// },
|
||||
// options: {
|
||||
// enabled: (isGalleryFocused || isImageViewerFocused) && isDeleteEnabledByTab && !isWorkflowsFocused,
|
||||
// },
|
||||
// dependencies: [isWorkflowsFocused, isDeleteEnabledByTab, selection, isWorkflowsFocused],
|
||||
// });
|
||||
const deleteImageModalApi = useDeleteImageModalApi();
|
||||
useRegisteredHotkeys({
|
||||
id: 'deleteSelection',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
const focusedRegion = getFocusedRegion();
|
||||
if (focusedRegion !== 'gallery' && focusedRegion !== 'viewer') {
|
||||
return;
|
||||
}
|
||||
const selection = selectSelection(getState());
|
||||
if (!selection.length) {
|
||||
return;
|
||||
}
|
||||
deleteImageModalApi.delete(selection);
|
||||
},
|
||||
dependencies: [getState, deleteImageModalApi],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,11 +21,15 @@ type UseImageUploadButtonArgs =
|
||||
isDisabled?: boolean;
|
||||
allowMultiple: false;
|
||||
onUpload?: (imageDTO: ImageDTO) => void;
|
||||
onUploadStarted?: (files: File) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
| {
|
||||
isDisabled?: boolean;
|
||||
allowMultiple: true;
|
||||
onUpload?: (imageDTOs: ImageDTO[]) => void;
|
||||
onUploadStarted?: (files: File[]) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
const log = logger('gallery');
|
||||
@@ -49,7 +53,13 @@ const log = logger('gallery');
|
||||
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click
|
||||
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
|
||||
*/
|
||||
export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: UseImageUploadButtonArgs) => {
|
||||
export const useImageUploadButton = ({
|
||||
onUpload,
|
||||
isDisabled,
|
||||
allowMultiple,
|
||||
onUploadStarted,
|
||||
onError,
|
||||
}: UseImageUploadButtonArgs) => {
|
||||
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
|
||||
const isClientSideUploadEnabled = useAppSelector(selectIsClientSideUploadEnabled);
|
||||
const [uploadImage, request] = useUploadImageMutation();
|
||||
@@ -71,6 +81,7 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
|
||||
}
|
||||
const file = files[0];
|
||||
assert(file !== undefined); // should never happen
|
||||
onUploadStarted?.(file);
|
||||
const imageDTO = await uploadImage({
|
||||
file,
|
||||
image_category: 'user',
|
||||
@@ -82,6 +93,8 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
|
||||
onUpload(imageDTO);
|
||||
}
|
||||
} else {
|
||||
onUploadStarted?.(files);
|
||||
|
||||
let imageDTOs: ImageDTO[] = [];
|
||||
if (isClientSideUploadEnabled && files.length > 1) {
|
||||
imageDTOs = await Promise.all(files.map((file, i) => clientSideUpload(file, i)));
|
||||
@@ -102,6 +115,7 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
toast({
|
||||
id: 'UPLOAD_FAILED',
|
||||
title: t('toast.imageUploadFailed'),
|
||||
@@ -109,7 +123,17 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
|
||||
});
|
||||
}
|
||||
},
|
||||
[allowMultiple, autoAddBoardId, onUpload, uploadImage, isClientSideUploadEnabled, clientSideUpload, t]
|
||||
[
|
||||
allowMultiple,
|
||||
onUploadStarted,
|
||||
uploadImage,
|
||||
autoAddBoardId,
|
||||
onUpload,
|
||||
isClientSideUploadEnabled,
|
||||
clientSideUpload,
|
||||
onError,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const onDropRejected = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { debounce } from 'es-toolkit/compat';
|
||||
import type { Dimensions } from 'features/controlLayers/store/types';
|
||||
import { selectUiSlice, textAreaSizesStateChanged } from 'features/ui/store/uiSlice';
|
||||
|
||||
@@ -57,7 +57,7 @@ export class Err<E> {
|
||||
* @template T The type of the value in the `Ok` case.
|
||||
* @template E The type of the error in the `Err` case.
|
||||
*/
|
||||
type Result<T, E = Error> = Ok<T> | Err<E>;
|
||||
export type Result<T, E = Error> = Ok<T> | Err<E>;
|
||||
|
||||
/**
|
||||
* Creates a successful result.
|
||||
@@ -89,7 +89,7 @@ export function withResult<T>(fn: () => T): Result<T> {
|
||||
try {
|
||||
return new Ok(fn());
|
||||
} catch (error) {
|
||||
return new Err(error instanceof Error ? error : new Error(String(error)));
|
||||
return new Err(error instanceof Error ? error : new WrappedError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,23 @@ export async function withResultAsync<T>(fn: () => Promise<T>): Promise<Result<T
|
||||
const result = await fn();
|
||||
return new Ok(result);
|
||||
} catch (error) {
|
||||
return new Err(error instanceof Error ? error : new Error(String(error)));
|
||||
return new Err(error instanceof Error ? error : new WrappedError(error));
|
||||
}
|
||||
}
|
||||
|
||||
export class WrappedError extends Error {
|
||||
error: unknown;
|
||||
|
||||
constructor(error: unknown) {
|
||||
super('Wrapped Error');
|
||||
this.name = this.constructor.name;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
static wrap(error: unknown): Error | WrappedError {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new WrappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
ContextMenu,
|
||||
Divider,
|
||||
Flex,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { 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 { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems';
|
||||
import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems';
|
||||
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
|
||||
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 { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel';
|
||||
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
|
||||
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
|
||||
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
|
||||
import { Transform } from 'features/controlLayers/components/Transform/Transform';
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
|
||||
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
|
||||
|
||||
const FOCUS_REGION_STYLES: SystemStyleObject = {
|
||||
width: 'full',
|
||||
height: 'full',
|
||||
};
|
||||
|
||||
const MenuContent = memo(() => {
|
||||
return (
|
||||
<CanvasManagerProviderGate>
|
||||
<MenuList>
|
||||
<CanvasContextMenuSelectedEntityMenuItems />
|
||||
<CanvasContextMenuGlobalMenuItems />
|
||||
</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 AdvancedSession = memo(({ id }: { id: string | null }) => {
|
||||
const dynamicGrid = useAppSelector(selectDynamicGrid);
|
||||
const showHUD = useAppSelector(selectShowHUD);
|
||||
|
||||
const renderMenu = useCallback(() => {
|
||||
return <MenuContent />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tabs w="full" h="full">
|
||||
<TabList>
|
||||
<Tab>Welcome</Tab>
|
||||
<Tab>Workspace</Tab>
|
||||
<Tab>Viewer</Tab>
|
||||
</TabList>
|
||||
<TabPanels w="full" h="full">
|
||||
<TabPanel w="full" h="full" justifyContent="center">
|
||||
<GenerateLaunchpadPanel />
|
||||
</TabPanel>
|
||||
<TabPanel w="full" h="full">
|
||||
<FocusRegionWrapper region="canvas" sx={FOCUS_REGION_STYLES}>
|
||||
<Flex
|
||||
tabIndex={-1}
|
||||
borderRadius="base"
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
height="full"
|
||||
width="full"
|
||||
gap={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
>
|
||||
<CanvasManagerProviderGate>
|
||||
<CanvasToolbar />
|
||||
</CanvasManagerProviderGate>
|
||||
<Divider />
|
||||
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} withLongPress={false}>
|
||||
{(ref) => (
|
||||
<Flex ref={ref} sx={canvasBgSx} data-dynamic-grid={dynamicGrid}>
|
||||
<InvokeCanvasComponent />
|
||||
<CanvasManagerProviderGate>
|
||||
<Flex
|
||||
position="absolute"
|
||||
flexDir="column"
|
||||
top={1}
|
||||
insetInlineStart={1}
|
||||
pointerEvents="none"
|
||||
gap={2}
|
||||
alignItems="flex-start"
|
||||
>
|
||||
{showHUD && <CanvasHUD />}
|
||||
<CanvasAlertsSelectedEntityStatus />
|
||||
<CanvasAlertsPreserveMask />
|
||||
<CanvasAlertsInvocationProgress />
|
||||
</Flex>
|
||||
<Flex position="absolute" top={1} insetInlineEnd={1}>
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} icon={<PiDotsThreeOutlineVerticalFill />} colorScheme="base" />
|
||||
<MenuContent />
|
||||
</Menu>
|
||||
</Flex>
|
||||
</CanvasManagerProviderGate>
|
||||
</Flex>
|
||||
)}
|
||||
</ContextMenu>
|
||||
{id !== null && (
|
||||
<CanvasManagerProviderGate>
|
||||
<CanvasSessionContextProvider type="advanced" id={id}>
|
||||
<Flex
|
||||
position="absolute"
|
||||
flexDir="column"
|
||||
bottom={4}
|
||||
gap={2}
|
||||
align="center"
|
||||
justify="center"
|
||||
left={4}
|
||||
right={4}
|
||||
>
|
||||
<Flex position="relative" maxW="full" w="full" h={108}>
|
||||
<StagingAreaItemsList />
|
||||
</Flex>
|
||||
<Flex gap={2}>
|
||||
<StagingAreaToolbar />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</CanvasSessionContextProvider>
|
||||
</CanvasManagerProviderGate>
|
||||
)}
|
||||
<Flex position="absolute" bottom={4}>
|
||||
<CanvasManagerProviderGate>
|
||||
<Filter />
|
||||
<Transform />
|
||||
<SelectObject />
|
||||
</CanvasManagerProviderGate>
|
||||
</Flex>
|
||||
<CanvasManagerProviderGate>
|
||||
<CanvasDropArea />
|
||||
</CanvasManagerProviderGate>
|
||||
</Flex>
|
||||
</FocusRegionWrapper>
|
||||
</TabPanel>
|
||||
<TabPanel w="full" h="full">
|
||||
<Flex flexDir="column" w="full" h="full">
|
||||
<ViewerToolbar />
|
||||
<ImageViewer />
|
||||
</Flex>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
});
|
||||
AdvancedSession.displayName = 'AdvancedSession';
|
||||
@@ -10,6 +10,7 @@ import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScro
|
||||
import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
|
||||
import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
|
||||
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
|
||||
import { RasterLayerExportPSDButton } from 'features/controlLayers/components/RasterLayer/RasterLayerExportPSDButton';
|
||||
import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover';
|
||||
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
|
||||
import { entitiesReordered } from 'features/controlLayers/store/canvasSlice';
|
||||
@@ -166,6 +167,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
|
||||
</Flex>
|
||||
<CanvasEntityMergeVisibleButton type={type} />
|
||||
<CanvasEntityTypeIsHiddenToggle type={type} />
|
||||
{type === 'raster_layer' && <RasterLayerExportPSDButton />}
|
||||
<CanvasEntityAddOfTypeButton type={type} />
|
||||
</Flex>
|
||||
<Collapse in={collapse.isTrue} style={fixTooltipCloseOnScrollStyles}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EntityListSelectedEntityActionBarFilterButton } from 'features/controlL
|
||||
import { EntityListSelectedEntityActionBarOpacity } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity';
|
||||
import { EntityListSelectedEntityActionBarSelectObjectButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton';
|
||||
import { EntityListSelectedEntityActionBarTransformButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton';
|
||||
import { EntityListNonRasterLayerToggle } from 'features/controlLayers/components/common/CanvasNonRasterLayersIsHiddenToggle';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { EntityListSelectedEntityActionBarSaveToAssetsButton } from './EntityListSelectedEntityActionBarSaveToAssetsButton';
|
||||
@@ -22,6 +23,7 @@ export const EntityListSelectedEntityActionBar = memo(() => {
|
||||
<EntityListSelectedEntityActionBarTransformButton />
|
||||
<EntityListSelectedEntityActionBarSaveToAssetsButton />
|
||||
<EntityListSelectedEntityActionBarDuplicateButton />
|
||||
<EntityListNonRasterLayerToggle />
|
||||
<EntityListGlobalActionBarAddLayerMenu />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -14,7 +14,7 @@ export const CanvasLayersPanel = memo(() => {
|
||||
|
||||
return (
|
||||
<CanvasManagerProviderGate>
|
||||
<Flex flexDir="column" gap={2} w="full" h="full" p={2}>
|
||||
<Flex flexDir="column" gap={2} w="full" h="full">
|
||||
<EntityListSelectedEntityActionBar />
|
||||
<Divider py={0} />
|
||||
<ParamDenoisingStrength />
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
ModalOverlay,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Flex, IconButton } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
|
||||
import { Weight } from 'features/controlLayers/components/common/Weight';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { usePullBboxIntoLayer } from 'features/controlLayers/hooks/saveCanvasHooks';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
import { useExportCanvasToPSD } from 'features/controlLayers/hooks/useExportCanvasToPSD';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFileArrowDownBold } from 'react-icons/pi';
|
||||
|
||||
export const RasterLayerExportPSDButton = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const isBusy = useCanvasIsBusy();
|
||||
const { exportCanvasToPSD } = useExportCanvasToPSD();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
exportCanvasToPSD();
|
||||
}, [exportCanvasToPSD]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={onClick}
|
||||
isDisabled={isBusy}
|
||||
size="sm"
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
aria-label={t('controlLayers.exportCanvasToPSD')}
|
||||
tooltip={t('controlLayers.exportCanvasToPSD')}
|
||||
icon={<PiFileArrowDownBold />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
RasterLayerExportPSDButton.displayName = 'RasterLayerExportPSDButton';
|
||||
@@ -4,9 +4,13 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
|
||||
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
|
||||
import { refImageDeleted, selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice';
|
||||
import {
|
||||
refImageDeleted,
|
||||
refImageIsEnabledToggled,
|
||||
selectRefImageEntityIds,
|
||||
} from 'features/controlLayers/store/refImagesSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { PiTrashBold } from 'react-icons/pi';
|
||||
import { PiCircleBold, PiCircleFill, PiTrashBold } from 'react-icons/pi';
|
||||
|
||||
const textSx: SystemStyleObject = {
|
||||
color: 'base.300',
|
||||
@@ -28,21 +32,41 @@ export const RefImageHeader = memo(() => {
|
||||
dispatch(refImageDeleted({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const toggleIsEnabled = useCallback(() => {
|
||||
dispatch(refImageIsEnabledToggled({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
return (
|
||||
<Flex justifyContent="space-between" alignItems="center" w="full" ps={2}>
|
||||
<Text fontWeight="semibold" sx={textSx} data-is-error={!entity.config.image}>
|
||||
Reference Image #{refImageNumber}
|
||||
</Text>
|
||||
<IconButton
|
||||
tooltip="Delete Reference Image"
|
||||
size="xs"
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
aria-label="Delete ref image"
|
||||
onClick={deleteRefImage}
|
||||
icon={<PiTrashBold />}
|
||||
colorScheme="error"
|
||||
/>
|
||||
<Flex alignItems="center" gap={1}>
|
||||
{!entity.isEnabled && (
|
||||
<Text fontSize="xs" fontStyle="italic" color="base.400">
|
||||
Disabled
|
||||
</Text>
|
||||
)}
|
||||
<IconButton
|
||||
tooltip={entity.isEnabled ? 'Disable Reference Image' : 'Enable Reference Image'}
|
||||
size="xs"
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
aria-label={entity.isEnabled ? 'Disable ref image' : 'Enable ref image'}
|
||||
onClick={toggleIsEnabled}
|
||||
icon={entity.isEnabled ? <PiCircleFill /> : <PiCircleBold />}
|
||||
/>
|
||||
<IconButton
|
||||
tooltip="Delete Reference Image"
|
||||
size="xs"
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
aria-label="Delete ref image"
|
||||
onClick={deleteRefImage}
|
||||
icon={<PiTrashBold />}
|
||||
colorScheme="error"
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button, Collapse, Divider, Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { RefImagePreview } from 'features/controlLayers/components/RefImage/RefImagePreview';
|
||||
import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
|
||||
|
||||
@@ -12,13 +12,16 @@ import {
|
||||
} from 'features/controlLayers/store/refImagesSlice';
|
||||
import { isIPAdapterConfig } from 'features/controlLayers/store/types';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { PiExclamationMarkBold, PiImageBold } from 'react-icons/pi';
|
||||
import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
|
||||
const baseSx: SystemStyleObject = {
|
||||
'&[data-is-open="true"]': {
|
||||
borderColor: 'invokeBlue.300',
|
||||
},
|
||||
'&[data-is-disabled="true"]': {
|
||||
opacity: 0.4,
|
||||
},
|
||||
};
|
||||
|
||||
const weightDisplaySx: SystemStyleObject = {
|
||||
@@ -36,6 +39,9 @@ const getImageSxWithWeight = (weight: number): SystemStyleObject => {
|
||||
|
||||
return {
|
||||
...baseSx,
|
||||
'&[data-is-disabled="true"]': {
|
||||
opacity: 0.4,
|
||||
},
|
||||
_after: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
@@ -97,6 +103,7 @@ export const RefImagePreview = memo(() => {
|
||||
flexShrink={0}
|
||||
data-is-open={selectedEntityId === id && isPanelOpen}
|
||||
data-is-error={true}
|
||||
data-is-disabled={!entity.isEnabled}
|
||||
sx={sx}
|
||||
/>
|
||||
);
|
||||
@@ -114,6 +121,7 @@ export const RefImagePreview = memo(() => {
|
||||
sx={sx}
|
||||
data-is-open={selectedEntityId === id && isPanelOpen}
|
||||
data-is-error={!entity.config.model}
|
||||
data-is-disabled={!entity.isEnabled}
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
cursor="pointer"
|
||||
@@ -144,7 +152,18 @@ export const RefImagePreview = memo(() => {
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{!entity.config.model && (
|
||||
{!entity.isEnabled ? (
|
||||
<Icon
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translateX(-50%) translateY(-50%)"
|
||||
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
|
||||
color="base.100"
|
||||
boxSize={6}
|
||||
as={PiEyeSlashBold}
|
||||
/>
|
||||
) : !entity.config.model ? (
|
||||
<Icon
|
||||
position="absolute"
|
||||
top="50%"
|
||||
@@ -152,10 +171,10 @@ export const RefImagePreview = memo(() => {
|
||||
transform="translateX(-50%) translateY(-50%)"
|
||||
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
|
||||
color="error.500"
|
||||
boxSize={16}
|
||||
boxSize={6}
|
||||
as={PiExclamationMarkBold}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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.referenceImageEmptyStateWithCanvasTab" components={components} />
|
||||
<Trans i18nKey="controlLayers.referenceImageEmptyStateWithCanvasOptions" components={components} />
|
||||
</Text>
|
||||
</Flex>
|
||||
<input {...uploadApi.getUploadInputProps()} />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
Divider,
|
||||
Flex,
|
||||
Icon,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Text,
|
||||
useShiftModifier,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { CanvasSettingsBboxOverlaySwitch } from 'features/controlLayers/components/Settings/CanvasSettingsBboxOverlaySwitch';
|
||||
@@ -23,11 +25,12 @@ import { CanvasSettingsOutputOnlyMaskedRegionsCheckbox } from 'features/controlL
|
||||
import { CanvasSettingsPreserveMaskCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPreserveMaskCheckbox';
|
||||
import { CanvasSettingsPressureSensitivityCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity';
|
||||
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
|
||||
import { CanvasSettingsRuleOfThirdsSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsRuleOfThirdsGuideSwitch';
|
||||
import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch';
|
||||
import { CanvasSettingsShowProgressOnCanvas } from 'features/controlLayers/components/Settings/CanvasSettingsShowProgressOnCanvasSwitch';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiGearSixFill } from 'react-icons/pi';
|
||||
import { PiCodeFill, PiEyeFill, PiGearSixFill, PiPencilFill, PiSquaresFourFill } from 'react-icons/pi';
|
||||
|
||||
export const CanvasSettingsPopover = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,22 +44,57 @@ export const CanvasSettingsPopover = memo(() => {
|
||||
alignSelf="stretch"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverContent maxW="280px">
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<Flex direction="column" gap={2}>
|
||||
<CanvasSettingsInvertScrollCheckbox />
|
||||
<CanvasSettingsPreserveMaskCheckbox />
|
||||
<CanvasSettingsClipToBboxCheckbox />
|
||||
<CanvasSettingsOutputOnlyMaskedRegionsCheckbox />
|
||||
<CanvasSettingsSnapToGridCheckbox />
|
||||
<CanvasSettingsPressureSensitivityCheckbox />
|
||||
<CanvasSettingsShowProgressOnCanvas />
|
||||
<CanvasSettingsIsolatedStagingPreviewSwitch />
|
||||
<CanvasSettingsIsolatedLayerPreviewSwitch />
|
||||
<CanvasSettingsDynamicGridSwitch />
|
||||
<CanvasSettingsBboxOverlaySwitch />
|
||||
<CanvasSettingsShowHUDSwitch />
|
||||
{/* Behavior Settings */}
|
||||
<Flex direction="column" gap={1}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Icon as={PiPencilFill} boxSize={4} />
|
||||
<Text fontWeight="bold" fontSize="sm" color="base.100">
|
||||
{t('hotkeys.canvas.settings.behavior')}
|
||||
</Text>
|
||||
</Flex>
|
||||
<CanvasSettingsInvertScrollCheckbox />
|
||||
<CanvasSettingsPressureSensitivityCheckbox />
|
||||
<CanvasSettingsPreserveMaskCheckbox />
|
||||
<CanvasSettingsClipToBboxCheckbox />
|
||||
<CanvasSettingsOutputOnlyMaskedRegionsCheckbox />
|
||||
</Flex>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Display Settings */}
|
||||
<Flex direction="column" gap={1}>
|
||||
<Flex align="center" gap={2} color="base.200">
|
||||
<Icon as={PiEyeFill} boxSize={4} />
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{t('hotkeys.canvas.settings.display')}
|
||||
</Text>
|
||||
</Flex>
|
||||
<CanvasSettingsShowProgressOnCanvas />
|
||||
<CanvasSettingsIsolatedStagingPreviewSwitch />
|
||||
<CanvasSettingsIsolatedLayerPreviewSwitch />
|
||||
<CanvasSettingsBboxOverlaySwitch />
|
||||
<CanvasSettingsShowHUDSwitch />
|
||||
</Flex>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Grid Settings */}
|
||||
<Flex direction="column" gap={1}>
|
||||
<Flex align="center" gap={2} color="base.200">
|
||||
<Icon as={PiSquaresFourFill} boxSize={4} />
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{t('hotkeys.canvas.settings.grid')}
|
||||
</Text>
|
||||
</Flex>
|
||||
<CanvasSettingsSnapToGridCheckbox />
|
||||
<CanvasSettingsDynamicGridSwitch />
|
||||
<CanvasSettingsRuleOfThirdsSwitch />
|
||||
</Flex>
|
||||
|
||||
<DebugSettings />
|
||||
</Flex>
|
||||
</PopoverBody>
|
||||
@@ -68,6 +106,7 @@ export const CanvasSettingsPopover = memo(() => {
|
||||
CanvasSettingsPopover.displayName = 'CanvasSettingsPopover';
|
||||
|
||||
const DebugSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const shift = useShiftModifier();
|
||||
|
||||
if (!shift) {
|
||||
@@ -77,10 +116,18 @@ const DebugSettings = () => {
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<CanvasSettingsClearCachesButton />
|
||||
<CanvasSettingsRecalculateRectsButton />
|
||||
<CanvasSettingsLogDebugInfoButton />
|
||||
<CanvasSettingsClearHistoryButton />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Flex align="center" gap={2} color="base.200">
|
||||
<Icon as={PiCodeFill} boxSize={4} />
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{t('hotkeys.canvas.settings.debug')}
|
||||
</Text>
|
||||
</Flex>
|
||||
<CanvasSettingsClearCachesButton />
|
||||
<CanvasSettingsRecalculateRectsButton />
|
||||
<CanvasSettingsLogDebugInfoButton />
|
||||
<CanvasSettingsClearHistoryButton />
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectRuleOfThirds, settingsRuleOfThirdsToggled } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const CanvasSettingsRuleOfThirdsSwitch = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const ruleOfThirds = useAppSelector(selectRuleOfThirds);
|
||||
const onChange = useCallback(() => {
|
||||
dispatch(settingsRuleOfThirdsToggled());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel m={0} flexGrow={1}>
|
||||
{t('controlLayers.ruleOfThirds')}
|
||||
</FormLabel>
|
||||
<Switch size="sm" isChecked={ruleOfThirds} onChange={onChange} />
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasSettingsRuleOfThirdsSwitch.displayName = 'CanvasSettingsRuleOfThirdsSwitch';
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library';
|
||||
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { InitialStateMainModelPicker } from './InitialStateMainModelPicker';
|
||||
import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference';
|
||||
@@ -10,22 +12,28 @@ import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButt
|
||||
import { LaunchpadUseALayoutImageButton } from './LaunchpadUseALayoutImageButton';
|
||||
|
||||
export const CanvasLaunchpadPanel = memo(() => {
|
||||
const ctx = useAutoLayoutContext();
|
||||
const { t } = useTranslation();
|
||||
const { tab } = useAutoLayoutContext();
|
||||
const focusCanvas = useCallback(() => {
|
||||
ctx.focusPanel(WORKSPACE_PANEL_ID);
|
||||
}, [ctx]);
|
||||
navigationApi.focusPanelInTab(tab, WORKSPACE_PANEL_ID);
|
||||
}, [tab]);
|
||||
return (
|
||||
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
|
||||
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
|
||||
<Heading mb={4}>Edit and refine on Canvas.</Heading>
|
||||
<Heading mb={4}>{t('ui.launchpad.canvasTitle')}</Heading>
|
||||
<Flex flexDir="column" gap={8}>
|
||||
<Grid gridTemplateColumns="1fr 1fr" gap={8}>
|
||||
<InitialStateMainModelPicker />
|
||||
<Flex flexDir="column" gap={2} justifyContent="center">
|
||||
<Text>
|
||||
Want to learn what prompts work best for each model?{' '}
|
||||
<Button as="a" variant="link" href="#" size="sm">
|
||||
Check our our Model Guide.
|
||||
{t('ui.launchpad.modelGuideText')}{' '}
|
||||
<Button
|
||||
as="a"
|
||||
variant="link"
|
||||
href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide"
|
||||
size="sm"
|
||||
>
|
||||
{t('ui.launchpad.modelGuideLink')}
|
||||
</Button>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -23,8 +23,13 @@ export const GenerateLaunchpadPanel = memo(() => {
|
||||
<Flex flexDir="column" gap={2} justifyContent="center">
|
||||
<Text>
|
||||
Want to learn what prompts work best for each model?{' '}
|
||||
<Button as="a" variant="link" href="#" size="sm">
|
||||
Check our our Model Guide.
|
||||
<Button
|
||||
as="a"
|
||||
variant="link"
|
||||
href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide"
|
||||
size="sm"
|
||||
>
|
||||
Check out our Model Guide.
|
||||
</Button>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ButtonGroupProps } from '@invoke-ai/ui-library';
|
||||
import { Button, ButtonGroup } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { newCanvasFromImage } from 'features/imageActions/actions';
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton';
|
||||
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
|
||||
|
||||
@@ -11,9 +11,12 @@ export const LaunchpadButton = memo(
|
||||
display="flex"
|
||||
position="relative"
|
||||
alignItems="center"
|
||||
justifyContent="left"
|
||||
borderWidth={1}
|
||||
borderRadius="base"
|
||||
p={4}
|
||||
pe={6}
|
||||
pb={6}
|
||||
ps={8}
|
||||
pt={6}
|
||||
gap={2}
|
||||
w="full"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton';
|
||||
import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { newCanvasFromImageDndTarget } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
|
||||
@@ -17,8 +17,8 @@ export const StagingAreaItemsList = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData);
|
||||
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId]);
|
||||
return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData, ctx.$isPending);
|
||||
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]);
|
||||
|
||||
return (
|
||||
<ScrollableContent overflowX="scroll" overflowY="hidden">
|
||||
|
||||
@@ -1,13 +1,173 @@
|
||||
import { Flex, Heading } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
import { Box, Button, ButtonGroup, Flex, Grid, Heading, Icon, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { setUpscaleInitialImageDndTarget } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
import {
|
||||
creativityChanged,
|
||||
selectCreativity,
|
||||
selectStructure,
|
||||
selectUpscaleInitialImage,
|
||||
structureChanged,
|
||||
upscaleInitialImageChanged,
|
||||
} from 'features/parameters/store/upscaleSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PiImageBold,
|
||||
PiPaletteBold,
|
||||
PiScalesBold,
|
||||
PiShieldCheckBold,
|
||||
PiSparkleBold,
|
||||
PiUploadBold,
|
||||
} from 'react-icons/pi';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { LaunchpadButton } from './LaunchpadButton';
|
||||
|
||||
export const UpscalingLaunchpadPanel = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const upscaleInitialImage = useAppSelector(selectUpscaleInitialImage);
|
||||
const creativity = useAppSelector(selectCreativity);
|
||||
const structure = useAppSelector(selectStructure);
|
||||
|
||||
const dndTargetData = useMemo(() => setUpscaleInitialImageDndTarget.getData(), []);
|
||||
|
||||
const onUpload = useCallback(
|
||||
(imageDTO: ImageDTO) => {
|
||||
dispatch(upscaleInitialImageChanged(imageDTO));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload });
|
||||
|
||||
// Preset button handlers
|
||||
const onConservativeClick = useCallback(() => {
|
||||
dispatch(creativityChanged(-5));
|
||||
dispatch(structureChanged(5));
|
||||
}, [dispatch]);
|
||||
|
||||
const onBalancedClick = useCallback(() => {
|
||||
dispatch(creativityChanged(0));
|
||||
dispatch(structureChanged(0));
|
||||
}, [dispatch]);
|
||||
|
||||
const onCreativeClick = useCallback(() => {
|
||||
dispatch(creativityChanged(5));
|
||||
dispatch(structureChanged(-2));
|
||||
}, [dispatch]);
|
||||
|
||||
const onArtisticClick = useCallback(() => {
|
||||
dispatch(creativityChanged(8));
|
||||
dispatch(structureChanged(-5));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
|
||||
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
|
||||
<Heading mb={4}>Upscale and add detail.</Heading>
|
||||
<Flex flexDir="column" w="full" gap={8} px={14} maxW={768} pt="20vh">
|
||||
<Heading>{t('ui.launchpad.upscalingTitle')}</Heading>
|
||||
|
||||
{/* Upload Area */}
|
||||
<LaunchpadButton {...uploadApi.getUploadButtonProps()} position="relative" gap={8}>
|
||||
{!upscaleInitialImage ? (
|
||||
<>
|
||||
<Icon as={PiImageBold} boxSize={8} color="base.500" />
|
||||
<Flex flexDir="column" alignItems="flex-start" gap={2}>
|
||||
<Heading size="sm">{t('ui.launchpad.upscaling.uploadImage.title')}</Heading>
|
||||
<Text color="base.300">{t('ui.launchpad.upscaling.uploadImage.description')}</Text>
|
||||
</Flex>
|
||||
<Flex position="absolute" right={3} bottom={3}>
|
||||
<PiUploadBold />
|
||||
<input {...uploadApi.getUploadInputProps()} />
|
||||
</Flex>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon as={PiImageBold} boxSize={8} color="base.500" />
|
||||
<Flex flexDir="column" alignItems="flex-start" gap={2}>
|
||||
<Heading size="sm">{t('ui.launchpad.upscaling.replaceImage.title')}</Heading>
|
||||
<Text color="base.300">{t('ui.launchpad.upscaling.replaceImage.description')}</Text>
|
||||
</Flex>
|
||||
<Flex position="absolute" right={3} bottom={3}>
|
||||
<PiUploadBold />
|
||||
<input {...uploadApi.getUploadInputProps()} />
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<DndDropTarget
|
||||
dndTarget={setUpscaleInitialImageDndTarget}
|
||||
dndTargetData={dndTargetData}
|
||||
label={t('gallery.drop')}
|
||||
/>
|
||||
</LaunchpadButton>
|
||||
|
||||
{/* Guidance text */}
|
||||
{upscaleInitialImage && (
|
||||
<Flex bg="base.800" p={4} borderRadius="base" border="1px solid" borderColor="base.700">
|
||||
<Text variant="subtext" fontSize="sm" lineHeight="1.6">
|
||||
<strong>{t('ui.launchpad.upscaling.readyToUpscale.title')}</strong>{' '}
|
||||
{t('ui.launchpad.upscaling.readyToUpscale.description')}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Grid gridTemplateColumns="1fr 1fr" gap={8} alignItems="start">
|
||||
{/* Left Column: Creativity and Structural Defaults */}
|
||||
<Box>
|
||||
<Text fontWeight="semibold" fontSize="sm" mb={3}>
|
||||
Creativity & Structure Defaults
|
||||
</Text>
|
||||
<ButtonGroup size="sm" orientation="vertical" variant="outline" w="full">
|
||||
<Button
|
||||
colorScheme={creativity === -5 && structure === 5 ? 'invokeBlue' : undefined}
|
||||
justifyContent="center"
|
||||
onClick={onConservativeClick}
|
||||
leftIcon={<PiShieldCheckBold />}
|
||||
>
|
||||
Conservative
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={creativity === 0 && structure === 0 ? 'invokeBlue' : undefined}
|
||||
justifyContent="center"
|
||||
onClick={onBalancedClick}
|
||||
leftIcon={<PiScalesBold />}
|
||||
>
|
||||
Balanced
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={creativity === 5 && structure === -2 ? 'invokeBlue' : undefined}
|
||||
justifyContent="center"
|
||||
onClick={onCreativeClick}
|
||||
leftIcon={<PiPaletteBold />}
|
||||
>
|
||||
Creative
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={creativity === 8 && structure === -5 ? 'invokeBlue' : undefined}
|
||||
justifyContent="center"
|
||||
onClick={onArtisticClick}
|
||||
leftIcon={<PiSparkleBold />}
|
||||
>
|
||||
Artistic
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
{/* Right Column: Description/help text */}
|
||||
<Box>
|
||||
<Text variant="subtext" fontSize="sm" lineHeight="1.6">
|
||||
{t('ui.launchpad.upscaling.helpText.promptAdvice')}
|
||||
</Text>
|
||||
<Text variant="subtext" fontSize="sm" lineHeight="1.6" mt={3}>
|
||||
{t('ui.launchpad.upscaling.helpText.styleAdvice')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
UpscalingLaunchpadPanel.displayName = 'UpscalingLaunchpadPanel';
|
||||
|
||||
@@ -1,13 +1,108 @@
|
||||
import { Flex, Heading } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
import { Button, Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
|
||||
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
|
||||
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { useNewWorkflow } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFilePlusBold, PiFolderOpenBold, PiUploadBold } from 'react-icons/pi';
|
||||
|
||||
import { LaunchpadButton } from './LaunchpadButton';
|
||||
|
||||
export const WorkflowsLaunchpadPanel = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
const newWorkflow = useNewWorkflow();
|
||||
|
||||
const handleBrowseTemplates = useCallback(() => {
|
||||
workflowLibraryModal.open();
|
||||
}, [workflowLibraryModal]);
|
||||
|
||||
const handleCreateNew = useCallback(() => {
|
||||
newWorkflow.createWithDialog();
|
||||
}, [newWorkflow]);
|
||||
|
||||
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
|
||||
|
||||
const onDropAccepted = useCallback(
|
||||
([file]: File[]) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
loadWorkflowWithDialog({
|
||||
type: 'file',
|
||||
data: file,
|
||||
});
|
||||
},
|
||||
[loadWorkflowWithDialog]
|
||||
);
|
||||
|
||||
const uploadApi = useDropzone({
|
||||
accept: { 'application/json': ['.json'] },
|
||||
onDropAccepted,
|
||||
noDrag: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
|
||||
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
|
||||
<Heading mb={4}>Go deep with Workflows.</Heading>
|
||||
<Heading>{t('ui.launchpad.workflowsTitle')}</Heading>
|
||||
|
||||
{/* Description */}
|
||||
<Text variant="subtext" fontSize="md" lineHeight="1.6">
|
||||
{t('ui.launchpad.workflows.description')}
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
<Button
|
||||
as="a"
|
||||
variant="link"
|
||||
href="https://support.invoke.ai/support/solutions/articles/151000189610-getting-started-with-workflows-denoise-latents"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{t('ui.launchpad.workflows.learnMoreLink')}
|
||||
</Button>
|
||||
</Text>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Flex flexDir="column" gap={8}>
|
||||
{/* Browse Workflow Templates */}
|
||||
<LaunchpadButton onClick={handleBrowseTemplates} position="relative" gap={8}>
|
||||
<Icon as={PiFolderOpenBold} boxSize={8} color="base.500" />
|
||||
<Flex flexDir="column" alignItems="flex-start" gap={2}>
|
||||
<Heading size="sm">{t('ui.launchpad.workflows.browseTemplates.title')}</Heading>
|
||||
<Text color="base.300">{t('ui.launchpad.workflows.browseTemplates.description')}</Text>
|
||||
</Flex>
|
||||
</LaunchpadButton>
|
||||
|
||||
{/* Create a new Workflow */}
|
||||
<LaunchpadButton onClick={handleCreateNew} position="relative" gap={8}>
|
||||
<Icon as={PiFilePlusBold} boxSize={8} color="base.500" />
|
||||
<Flex flexDir="column" alignItems="flex-start" gap={2}>
|
||||
<Heading size="sm">{t('ui.launchpad.workflows.createNew.title')}</Heading>
|
||||
<Text color="base.300">{t('ui.launchpad.workflows.createNew.description')}</Text>
|
||||
</Flex>
|
||||
</LaunchpadButton>
|
||||
|
||||
{/* Load workflow from existing image or file */}
|
||||
<LaunchpadButton {...uploadApi.getRootProps()} position="relative" gap={8}>
|
||||
<Icon as={PiUploadBold} boxSize={8} color="base.500" />
|
||||
<Flex flexDir="column" alignItems="flex-start" gap={2}>
|
||||
<Heading size="sm">{t('ui.launchpad.workflows.loadFromFile.title')}</Heading>
|
||||
<Text color="base.300">{t('ui.launchpad.workflows.loadFromFile.description')}</Text>
|
||||
</Flex>
|
||||
<Flex position="absolute" right={3} bottom={3}>
|
||||
<PiUploadBold />
|
||||
<input {...uploadApi.getInputProps()} />
|
||||
</Flex>
|
||||
</LaunchpadButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
WorkflowsLaunchpadPanel.displayName = 'WorkflowsLaunchpadPanel';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { buildZodTypeGuard } from 'common/util/zodUtils';
|
||||
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import type { ProgressImage } from 'features/nodes/types/common';
|
||||
@@ -92,6 +92,7 @@ type CanvasSessionContextValue = {
|
||||
$items: Atom<S['SessionQueueItem'][]>;
|
||||
$itemCount: Atom<number>;
|
||||
$hasItems: Atom<boolean>;
|
||||
$isPending: Atom<boolean>;
|
||||
$progressData: ProgressDataMap;
|
||||
$selectedItemId: WritableAtom<number | null>;
|
||||
$selectedItem: Atom<S['SessionQueueItem'] | null>;
|
||||
@@ -170,6 +171,13 @@ export const CanvasSessionContextProvider = memo(
|
||||
*/
|
||||
const $hasItems = useState(() => computed([$items], (items) => items.length > 0))[0];
|
||||
|
||||
/**
|
||||
* Whether there are any pending or in-progress items. Computed from the queue items array.
|
||||
*/
|
||||
const $isPending = useState(() =>
|
||||
computed([$items], (items) => items.some((item) => item.status === 'pending' || item.status === 'in_progress'))
|
||||
)[0];
|
||||
|
||||
/**
|
||||
* The currently selected queue item, or null if one is not selected.
|
||||
*/
|
||||
@@ -506,6 +514,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
session,
|
||||
$items,
|
||||
$hasItems,
|
||||
$isPending,
|
||||
$progressData,
|
||||
$selectedItemId,
|
||||
$autoSwitch,
|
||||
@@ -523,6 +532,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
$autoSwitch,
|
||||
$items,
|
||||
$hasItems,
|
||||
$isPending,
|
||||
$progressData,
|
||||
$selectedItem,
|
||||
$selectedItemId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MenuGroup, MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanv
|
||||
import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey';
|
||||
import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey';
|
||||
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
|
||||
import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey';
|
||||
import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey';
|
||||
import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys';
|
||||
import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity';
|
||||
@@ -26,6 +27,7 @@ export const CanvasToolbar = memo(() => {
|
||||
useNextPrevEntityHotkeys();
|
||||
useCanvasTransformHotkey();
|
||||
useCanvasFilterHotkey();
|
||||
useCanvasToggleNonRasterLayersHotkey();
|
||||
|
||||
return (
|
||||
<Flex w="full" gap={2} alignItems="center" px={2}>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { allNonRasterLayersIsHiddenToggled } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectNonRasterLayersIsHidden } from 'features/controlLayers/store/selectors';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi';
|
||||
|
||||
export const EntityListNonRasterLayerToggle = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const isHidden = useAppSelector(selectNonRasterLayersIsHidden);
|
||||
|
||||
const onClick = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(allNonRasterLayersIsHiddenToggled());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={t(isHidden ? 'controlLayers.showNonRasterLayers' : 'controlLayers.hideNonRasterLayers')}
|
||||
tooltip={t(isHidden ? 'controlLayers.showNonRasterLayers' : 'controlLayers.hideNonRasterLayers')}
|
||||
variant="link"
|
||||
icon={isHidden ? <PiEyeClosedBold /> : <PiEyeBold />}
|
||||
onClick={onClick}
|
||||
alignSelf="stretch"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
EntityListNonRasterLayerToggle.displayName = 'EntityListNonRasterLayerToggle';
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import type { AppGetState } from 'app/store/store';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useAppDispatch, useAppStore } from 'app/store/storeHooks';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { allNonRasterLayersIsHiddenToggled } from 'features/controlLayers/store/canvasSlice';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const useCanvasToggleNonRasterLayersHotkey = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleToggleNonRasterLayers = useCallback(() => {
|
||||
dispatch(allNonRasterLayersIsHiddenToggled());
|
||||
}, [dispatch]);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'toggleNonRasterLayers',
|
||||
category: 'canvas',
|
||||
callback: handleToggleNonRasterLayers,
|
||||
dependencies: [handleToggleNonRasterLayers],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { type Layer, type Psd, writePsd } from 'ag-psd';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { downloadBlob } from 'features/controlLayers/konva/util';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const log = logger('canvas');
|
||||
|
||||
// Canvas size limits for PSD export
|
||||
// These are conservative limits to prevent memory issues with large canvases
|
||||
// The actual limit may be lower depending on available memory
|
||||
const MAX_CANVAS_DIMENSION = 8192; // 8K resolution
|
||||
const MAX_CANVAS_AREA = MAX_CANVAS_DIMENSION * MAX_CANVAS_DIMENSION; // ~64MP max
|
||||
|
||||
export const useExportCanvasToPSD = () => {
|
||||
const { t } = useTranslation();
|
||||
const canvasManager = useCanvasManagerSafe();
|
||||
|
||||
const exportCanvasToPSD = useCallback(async () => {
|
||||
try {
|
||||
if (!canvasManager) {
|
||||
toast({
|
||||
id: 'CANVAS_MANAGER_NOT_AVAILABLE',
|
||||
title: t('toast.canvasManagerNotAvailable'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const adapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
|
||||
|
||||
if (adapters.length === 0) {
|
||||
toast({
|
||||
id: 'NO_VISIBLE_RASTER_LAYERS',
|
||||
title: t('toast.noVisibleRasterLayers'),
|
||||
description: t('toast.noVisibleRasterLayersDesc'),
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`Exporting ${adapters.length} visible raster layers to PSD`);
|
||||
|
||||
const visibleRect = canvasManager.compositor.getRectOfAdapters(adapters);
|
||||
|
||||
if (visibleRect.width <= 0 || visibleRect.height <= 0) {
|
||||
toast({
|
||||
id: 'INVALID_CANVAS_DIMENSIONS',
|
||||
title: t('toast.invalidCanvasDimensions'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasArea = visibleRect.width * visibleRect.height;
|
||||
if (canvasArea > MAX_CANVAS_AREA) {
|
||||
toast({
|
||||
id: 'CANVAS_TOO_LARGE',
|
||||
title: t('toast.canvasTooLarge'),
|
||||
description: t('toast.canvasTooLargeDesc'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`PSD canvas dimensions: ${visibleRect.width}x${visibleRect.height}`);
|
||||
|
||||
const psdLayers: Layer[] = await Promise.all(
|
||||
adapters.map((adapter, index) => {
|
||||
const layer = adapter.state;
|
||||
|
||||
// Get the actual content bounds for this layer (excluding transparent regions)
|
||||
const layerPosition = adapter.state.position;
|
||||
const pixelRect = adapter.transformer.$pixelRect.get();
|
||||
|
||||
// Calculate the layer's content bounds in stage coordinates
|
||||
const layerContentBounds = {
|
||||
x: layerPosition.x + pixelRect.x,
|
||||
y: layerPosition.y + pixelRect.y,
|
||||
width: pixelRect.width,
|
||||
height: pixelRect.height,
|
||||
};
|
||||
|
||||
// Get the canvas cropped to the layer's actual content bounds
|
||||
const canvas = adapter.getCanvas(layerContentBounds);
|
||||
|
||||
const layerDataPSD: Layer = {
|
||||
name: layer.name || `Layer ${index + 1}`,
|
||||
// Position relative to the visible rect, using the actual content bounds
|
||||
left: Math.floor(layerContentBounds.x - visibleRect.x),
|
||||
top: Math.floor(layerContentBounds.y - visibleRect.y),
|
||||
right: Math.floor(layerContentBounds.x - visibleRect.x + canvas.width),
|
||||
bottom: Math.floor(layerContentBounds.y - visibleRect.y + canvas.height),
|
||||
opacity: Math.floor(layer.opacity * 255),
|
||||
hidden: false,
|
||||
blendMode: 'normal',
|
||||
canvas: canvas,
|
||||
};
|
||||
|
||||
log.debug(
|
||||
`Layer "${layerDataPSD.name}": ${layerDataPSD.left},${layerDataPSD.top} to ${layerDataPSD.right},${layerDataPSD.bottom}`
|
||||
);
|
||||
|
||||
return layerDataPSD;
|
||||
})
|
||||
);
|
||||
|
||||
const psd: Psd = {
|
||||
width: visibleRect.width,
|
||||
height: visibleRect.height,
|
||||
channels: 3,
|
||||
bitsPerChannel: 8,
|
||||
colorMode: 3, // RGB mode
|
||||
children: psdLayers,
|
||||
};
|
||||
|
||||
log.debug(
|
||||
{
|
||||
layerCount: psd.children?.length ?? 0,
|
||||
canvasDimensions: { width: psd.width, height: psd.height },
|
||||
layers:
|
||||
psd.children?.map((l) => ({
|
||||
name: l.name,
|
||||
bounds: { left: l.left, top: l.top, right: l.right, bottom: l.bottom },
|
||||
})) ?? [],
|
||||
},
|
||||
'Creating PSD with layers'
|
||||
);
|
||||
|
||||
const buffer = writePsd(psd);
|
||||
|
||||
const blob = new Blob([buffer], { type: 'application/octet-stream' });
|
||||
const fileName = `canvas-layers-${new Date().toISOString().slice(0, 10)}.psd`;
|
||||
downloadBlob(blob, fileName);
|
||||
|
||||
toast({
|
||||
id: 'PSD_EXPORT_SUCCESS',
|
||||
title: t('toast.psdExportSuccess'),
|
||||
description: t('toast.psdExportSuccessDesc', { count: psd.children?.length ?? 0 }),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
log.debug('Successfully exported canvas to PSD');
|
||||
} catch (error) {
|
||||
log.error({ error: parseify(error) }, 'Problem exporting canvas to PSD');
|
||||
toast({
|
||||
id: 'PROBLEM_EXPORTING_PSD',
|
||||
title: t('toast.problemExportingPSD'),
|
||||
description: String(error),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
}, [canvasManager, t]);
|
||||
|
||||
return { exportCanvasToPSD };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { $canvasManager } from 'features/controlLayers/store/ephemeral';
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { selectRuleOfThirds } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { selectBbox } from 'features/controlLayers/store/selectors';
|
||||
import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
/**
|
||||
* Renders the rule of thirds composition guide overlay on the canvas.
|
||||
* The guide shows a 3x3 grid within the bounding box to help with composition.
|
||||
*/
|
||||
export class CanvasCompositionGuideModule extends CanvasModuleBase {
|
||||
readonly type = 'composition_guide';
|
||||
readonly id: string;
|
||||
readonly path: string[];
|
||||
readonly parent: CanvasManager;
|
||||
readonly manager: CanvasManager;
|
||||
readonly log: Logger;
|
||||
|
||||
subscriptions: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* The Konva objects that make up the composition guide:
|
||||
* - A group to hold all the guide lines
|
||||
* - Individual line objects for the rule of thirds grid
|
||||
*/
|
||||
konva: {
|
||||
group: Konva.Group;
|
||||
verticalLine1: Konva.Line;
|
||||
verticalLine2: Konva.Line;
|
||||
horizontalLine1: Konva.Line;
|
||||
horizontalLine2: Konva.Line;
|
||||
};
|
||||
|
||||
constructor(manager: CanvasManager) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = manager;
|
||||
this.manager = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
|
||||
this.log.debug('Creating composition guide module');
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({
|
||||
name: `${this.type}:group`,
|
||||
listening: false,
|
||||
perfectDrawEnabled: false,
|
||||
}),
|
||||
verticalLine1: new Konva.Line({
|
||||
name: `${this.type}:vertical_line_1`,
|
||||
listening: false,
|
||||
stroke: 'hsl(220 12% 90% / 0.9)',
|
||||
strokeWidth: 1,
|
||||
strokeScaleEnabled: false,
|
||||
perfectDrawEnabled: false,
|
||||
dash: [5, 5],
|
||||
}),
|
||||
verticalLine2: new Konva.Line({
|
||||
name: `${this.type}:vertical_line_2`,
|
||||
listening: false,
|
||||
stroke: 'hsl(220 12% 90% / 0.9)',
|
||||
strokeWidth: 1,
|
||||
strokeScaleEnabled: false,
|
||||
perfectDrawEnabled: false,
|
||||
dash: [5, 5],
|
||||
}),
|
||||
horizontalLine1: new Konva.Line({
|
||||
name: `${this.type}:horizontal_line_1`,
|
||||
listening: false,
|
||||
stroke: 'hsl(220 12% 90% / 0.9)',
|
||||
strokeWidth: 1,
|
||||
strokeScaleEnabled: false,
|
||||
perfectDrawEnabled: false,
|
||||
dash: [5, 5],
|
||||
}),
|
||||
horizontalLine2: new Konva.Line({
|
||||
name: `${this.type}:horizontal_line_2`,
|
||||
listening: false,
|
||||
stroke: 'hsl(220 12% 90% / 0.9)',
|
||||
strokeWidth: 1,
|
||||
strokeScaleEnabled: false,
|
||||
perfectDrawEnabled: false,
|
||||
dash: [5, 5],
|
||||
}),
|
||||
};
|
||||
|
||||
this.konva.group.add(this.konva.verticalLine1);
|
||||
this.konva.group.add(this.konva.verticalLine2);
|
||||
this.konva.group.add(this.konva.horizontalLine1);
|
||||
this.konva.group.add(this.konva.horizontalLine2);
|
||||
|
||||
// Listen for changes to the rule of thirds guide setting
|
||||
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectRuleOfThirds, this.render));
|
||||
|
||||
// Listen for changes to the bbox to update guide positioning
|
||||
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectBbox, this.render));
|
||||
}
|
||||
|
||||
initialize = () => {
|
||||
this.log.debug('Initializing composition guide module');
|
||||
this.render();
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the composition guide. The guide is only visible when the setting is enabled.
|
||||
*/
|
||||
render = () => {
|
||||
const ruleOfThirds = this.manager.stateApi.getSettings().ruleOfThirds;
|
||||
const { x, y, width, height } = this.manager.stateApi.runSelector(selectBbox).rect;
|
||||
|
||||
this.konva.group.visible(ruleOfThirds);
|
||||
|
||||
if (!ruleOfThirds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the thirds positions of the bounding box
|
||||
const oneThirdX = x + width / 3;
|
||||
const twoThirdsX = x + (2 * width) / 3;
|
||||
const oneThirdY = y + height / 3;
|
||||
const twoThirdsY = y + (2 * height) / 3;
|
||||
|
||||
// Update the vertical lines (divide the bbox into thirds vertically)
|
||||
this.konva.verticalLine1.points([oneThirdX, y, oneThirdX, y + height]);
|
||||
this.konva.verticalLine2.points([twoThirdsX, y, twoThirdsX, y + height]);
|
||||
|
||||
// Update the horizontal lines (divide the bbox into thirds horizontally)
|
||||
this.konva.horizontalLine1.points([x, oneThirdY, x + width, oneThirdY]);
|
||||
this.konva.horizontalLine2.points([x, twoThirdsY, x + width, twoThirdsY]);
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying composition guide module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.subscriptions.clear();
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
visible: this.konva.group.visible(),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -274,8 +274,10 @@ export class CanvasEntityFilterer extends CanvasModuleBase {
|
||||
this.manager.stateApi.runGraphAndReturnImageOutput({
|
||||
graph,
|
||||
outputNodeId,
|
||||
prepend: true,
|
||||
signal: controller.signal,
|
||||
options: {
|
||||
prepend: true,
|
||||
signal: controller.signal,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/k
|
||||
import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters';
|
||||
import { getPatternSVG } from 'features/controlLayers/konva/patterns/getPatternSVG';
|
||||
import {
|
||||
areStageAttrsGonnaExplode,
|
||||
getKonvaNodeDebugAttrs,
|
||||
getPrefixedId,
|
||||
konvaNodeToBlob,
|
||||
@@ -138,6 +139,10 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
|
||||
return;
|
||||
}
|
||||
|
||||
if (areStageAttrsGonnaExplode(stageAttrs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stageAttrs.width !== oldStageAttrs.width ||
|
||||
stageAttrs.height !== oldStageAttrs.height ||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEnt
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import {
|
||||
areStageAttrsGonnaExplode,
|
||||
canvasToImageData,
|
||||
getEmptyRect,
|
||||
getKonvaNodeDebugAttrs,
|
||||
@@ -266,6 +267,9 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
|
||||
this.subscriptions.add(
|
||||
this.manager.stage.$stageAttrs.listen((newVal, oldVal) => {
|
||||
if (areStageAttrsGonnaExplode(newVal)) {
|
||||
return;
|
||||
}
|
||||
if (newVal.scale !== oldVal.scale) {
|
||||
this.syncScale();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { assert } from 'tsafe';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
import { CanvasBackgroundModule } from './CanvasBackgroundModule';
|
||||
import { CanvasCompositionGuideModule } from './CanvasCompositionGuideModule';
|
||||
import { CanvasStateApiModule } from './CanvasStateApiModule';
|
||||
|
||||
export class CanvasManager extends CanvasModuleBase {
|
||||
@@ -61,6 +62,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
compositor: CanvasCompositorModule;
|
||||
tool: CanvasToolModule;
|
||||
stagingArea: CanvasStagingAreaModule;
|
||||
compositionGuide: CanvasCompositionGuideModule;
|
||||
|
||||
konva: {
|
||||
previewLayer: Konva.Layer;
|
||||
@@ -101,6 +103,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
|
||||
this.compositor = new CanvasCompositorModule(this);
|
||||
this.stagingArea = new CanvasStagingAreaModule(this);
|
||||
this.compositionGuide = new CanvasCompositionGuideModule(this);
|
||||
|
||||
this.$isBusy = computed(
|
||||
[
|
||||
@@ -129,6 +132,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
// Must add in this order for correct z-index
|
||||
this.konva.previewLayer.add(this.stagingArea.konva.group);
|
||||
this.konva.previewLayer.add(this.tool.konva.group);
|
||||
this.konva.previewLayer.add(this.compositionGuide.konva.group);
|
||||
}
|
||||
|
||||
getAdapter = <T extends CanvasEntityType = CanvasEntityType>(
|
||||
@@ -236,6 +240,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
this.entityRenderer,
|
||||
this.compositor,
|
||||
this.stage,
|
||||
this.compositionGuide,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -281,6 +286,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
entityRenderer: this.entityRenderer.repr(),
|
||||
compositor: this.compositor.repr(),
|
||||
stage: this.stage.repr(),
|
||||
compositionGuide: this.compositionGuide.repr(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'
|
||||
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
|
||||
import {
|
||||
addCoords,
|
||||
areStageAttrsGonnaExplode,
|
||||
getKonvaNodeDebugAttrs,
|
||||
getPrefixedId,
|
||||
offsetCoord,
|
||||
@@ -443,6 +444,9 @@ export class CanvasSegmentAnythingModule extends CanvasModuleBase {
|
||||
// Scale the SAM points when the stage scale changes
|
||||
this.subscriptions.add(
|
||||
this.manager.stage.$stageAttrs.listen((stageAttrs, oldStageAttrs) => {
|
||||
if (areStageAttrsGonnaExplode(stageAttrs)) {
|
||||
return;
|
||||
}
|
||||
if (stageAttrs.scale !== oldStageAttrs.scale) {
|
||||
this.syncPointScales();
|
||||
}
|
||||
@@ -590,8 +594,10 @@ export class CanvasSegmentAnythingModule extends CanvasModuleBase {
|
||||
this.manager.stateApi.runGraphAndReturnImageOutput({
|
||||
graph,
|
||||
outputNodeId,
|
||||
prepend: true,
|
||||
signal: controller.signal,
|
||||
options: {
|
||||
prepend: true,
|
||||
signal: controller.signal,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,27 @@ import type { Atom } from 'nanostores';
|
||||
import { atom, effect } from 'nanostores';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
// To get pixel sizes corresponding to our theme tokens, first find the theme token CSS var in browser dev tools.
|
||||
// For example `var(--invoke-space-8)` is equivalent to using `8` as a space prop in a component.
|
||||
//
|
||||
// If it is already in pixels, you can use it directly. If it is in rems, you need to convert it to pixels.
|
||||
//
|
||||
// For example:
|
||||
// const style = window.getComputedStyle(document.documentElement)
|
||||
// parseFloat(style.fontSize) * parseFloat(style.getPropertyValue("--invoke-space-8"))
|
||||
//
|
||||
// This will give you the pixel value for the theme token in pixels.
|
||||
//
|
||||
// You cannot do this dynamically in this file, because it depends on the styles being applied to the document, which
|
||||
// will not have happened yet when this module is loaded.
|
||||
|
||||
const SPACING_4 = 12; // --invoke-space-4 in pixels
|
||||
const BORDER_RADIUS_BASE = 4; // --invoke-radii-base in pixels
|
||||
const BORDER_WIDTH = 1;
|
||||
const FONT_SIZE_MD = 14.4; // --invoke-fontSizes-md
|
||||
const BADGE_WIDTH = 192;
|
||||
const BADGE_HEIGHT = 36;
|
||||
|
||||
type ImageNameSrc = { type: 'imageName'; data: string };
|
||||
type DataURLSrc = { type: 'dataURL'; data: string };
|
||||
|
||||
@@ -27,7 +48,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
group: Konva.Group;
|
||||
placeholder: {
|
||||
group: Konva.Group;
|
||||
rect: Konva.Rect;
|
||||
badgeBg: Konva.Rect;
|
||||
text: Konva.Text;
|
||||
};
|
||||
};
|
||||
@@ -38,6 +59,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
|
||||
$shouldShowStagedImage = atom<boolean>(true);
|
||||
$isStaging = atom<boolean>(false);
|
||||
$isPending = atom<boolean>(false);
|
||||
|
||||
constructor(manager: CanvasManager) {
|
||||
super();
|
||||
@@ -49,8 +71,6 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
|
||||
this.log.debug('Creating module');
|
||||
|
||||
const { width, height } = this.manager.stateApi.getBbox().rect;
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({
|
||||
name: `${this.type}:group`,
|
||||
@@ -62,24 +82,31 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
listening: false,
|
||||
visible: false,
|
||||
}),
|
||||
rect: new Konva.Rect({
|
||||
name: `${this.type}:placeholder_rect`,
|
||||
fill: 'hsl(220 12% 10% / 1)', // 'base.900'
|
||||
width,
|
||||
height,
|
||||
badgeBg: new Konva.Rect({
|
||||
name: `${this.type}:placeholder_badge_bg`,
|
||||
fill: 'hsl(220 12% 10% / 0.8)', // 'base.900' with opacity
|
||||
x: SPACING_4,
|
||||
y: SPACING_4,
|
||||
width: BADGE_WIDTH,
|
||||
height: BADGE_HEIGHT,
|
||||
cornerRadius: BORDER_RADIUS_BASE,
|
||||
stroke: 'hsl(220 12% 50% / 1)', // 'base.700'
|
||||
strokeWidth: BORDER_WIDTH,
|
||||
listening: false,
|
||||
perfectDrawEnabled: false,
|
||||
}),
|
||||
text: new Konva.Text({
|
||||
name: `${this.type}:placeholder_text`,
|
||||
fill: 'hsl(220 12% 80% / 1)', // 'base.900'
|
||||
width,
|
||||
height,
|
||||
fill: 'hsl(220 12% 80% / 1)', // 'base.300'
|
||||
x: SPACING_4,
|
||||
y: SPACING_4,
|
||||
width: BADGE_WIDTH,
|
||||
height: BADGE_HEIGHT,
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
fontFamily: '"Inter Variable", sans-serif',
|
||||
fontSize: width / 24,
|
||||
fontStyle: '600',
|
||||
fontSize: FONT_SIZE_MD,
|
||||
fontStyle: '600', // Equivalent to theme fontWeight "semibold"
|
||||
text: 'Waiting for Image',
|
||||
listening: false,
|
||||
perfectDrawEnabled: false,
|
||||
@@ -87,7 +114,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
},
|
||||
};
|
||||
|
||||
this.konva.placeholder.group.add(this.konva.placeholder.rect);
|
||||
this.konva.placeholder.group.add(this.konva.placeholder.badgeBg);
|
||||
this.konva.placeholder.group.add(this.konva.placeholder.text);
|
||||
this.konva.group.add(this.konva.placeholder.group);
|
||||
|
||||
@@ -120,22 +147,17 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
);
|
||||
}
|
||||
|
||||
syncPlaceholderSize = () => {
|
||||
const { width, height } = this.manager.stateApi.getBbox().rect;
|
||||
this.konva.placeholder.rect.width(width);
|
||||
this.konva.placeholder.rect.height(height);
|
||||
this.konva.placeholder.text.width(width);
|
||||
this.konva.placeholder.text.height(height);
|
||||
this.konva.placeholder.text.fontSize(width / 24);
|
||||
};
|
||||
|
||||
initialize = () => {
|
||||
this.log.debug('Initializing module');
|
||||
this.render();
|
||||
this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging));
|
||||
};
|
||||
|
||||
connectToSession = ($selectedItemId: Atom<number | null>, $progressData: ProgressDataMap) => {
|
||||
connectToSession = (
|
||||
$selectedItemId: Atom<number | null>,
|
||||
$progressData: ProgressDataMap,
|
||||
$isPending: Atom<boolean>
|
||||
) => {
|
||||
const cb = (selectedItemId: number | null, progressData: Record<number, ProgressData | undefined>) => {
|
||||
if (!selectedItemId) {
|
||||
this.$imageSrc.set(null);
|
||||
@@ -159,7 +181,17 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
cb($selectedItemId.get(), $progressData.get());
|
||||
this.render();
|
||||
|
||||
return effect([$selectedItemId, $progressData], cb);
|
||||
// Sync the $isPending flag with the computed
|
||||
const unsubIsPending = effect([$isPending], (isPending) => {
|
||||
this.$isPending.set(isPending);
|
||||
});
|
||||
|
||||
const unsubImageSrc = effect([$selectedItemId, $progressData], cb);
|
||||
|
||||
return () => {
|
||||
unsubIsPending();
|
||||
unsubImageSrc();
|
||||
};
|
||||
};
|
||||
|
||||
private _getImageFromSrc = (
|
||||
@@ -189,6 +221,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
|
||||
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
|
||||
const shouldShowStagedImage = this.$shouldShowStagedImage.get();
|
||||
const isPending = this.$isPending.get();
|
||||
|
||||
this.konva.group.position({ x, y });
|
||||
|
||||
@@ -209,8 +242,8 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
} else {
|
||||
this.image?.destroy();
|
||||
this.image = null;
|
||||
this.syncPlaceholderSize();
|
||||
this.konva.placeholder.group.visible(true);
|
||||
// Only show placeholder if there are pending items, otherwise show nothing
|
||||
this.konva.placeholder.group.visible(isPending);
|
||||
}
|
||||
|
||||
this.konva.group.visible(shouldShowStagedImage && this.$isStaging.get());
|
||||
|
||||
@@ -2,7 +2,6 @@ import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library';
|
||||
import type { Selector } from '@reduxjs/toolkit';
|
||||
import { addAppListener } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStore, RootState } from 'app/store/store';
|
||||
import { withResultAsync } from 'common/util/result';
|
||||
import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer';
|
||||
import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
@@ -49,15 +48,15 @@ import type {
|
||||
RgbaColor,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import { RGBA_BLACK } from 'features/controlLayers/store/types';
|
||||
import { zImageOutput } from 'features/nodes/types/common';
|
||||
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
|
||||
import { atom, computed } from 'nanostores';
|
||||
import type { Logger } from 'roarr';
|
||||
import { getImageDTO } from 'services/api/endpoints/images';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
import type { EnqueueBatchArg, ImageDTO, S } from 'services/api/types';
|
||||
import { QueueError } from 'services/events/errors';
|
||||
import type { RunGraphOptions } from 'services/api/run-graph';
|
||||
import { buildRunGraphDependencies, runGraph } from 'services/api/run-graph';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import type { Param0 } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
import type { CanvasEntityAdapter } from './CanvasEntity/types';
|
||||
|
||||
@@ -266,213 +265,37 @@ export class CanvasStateApiModule extends CanvasModuleBase {
|
||||
* controller.abort();
|
||||
* ```
|
||||
*/
|
||||
runGraphAndReturnImageOutput = (arg: {
|
||||
runGraphAndReturnImageOutput = async (arg: {
|
||||
graph: Graph;
|
||||
outputNodeId: string;
|
||||
destination?: string;
|
||||
prepend?: boolean;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
options?: RunGraphOptions;
|
||||
}): Promise<ImageDTO> => {
|
||||
const { graph, outputNodeId, destination, prepend, timeout, signal } = arg;
|
||||
const dependencies = buildRunGraphDependencies(this.store.dispatch, this.manager.socket);
|
||||
|
||||
if (!graph.hasNode(outputNodeId)) {
|
||||
throw new Error(`Graph does not contain node with id: ${outputNodeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* We will use the origin to handle events from the graph. Ideally we'd just use the queue item's id, but there's a
|
||||
* race condition:
|
||||
* - The queue item id is not available until the graph is enqueued
|
||||
* - The graph may complete before we can set up the listeners to handle the completion event
|
||||
*
|
||||
* The origin is the only unique identifier we have that is guaranteed to be available before the graph is enqueued,
|
||||
* so we will use that to filter events.
|
||||
*/
|
||||
const origin = getPrefixedId(graph.id);
|
||||
|
||||
const batch: EnqueueBatchArg = {
|
||||
prepend,
|
||||
batch: {
|
||||
graph: graph.getGraph(),
|
||||
origin,
|
||||
destination,
|
||||
runs: 1,
|
||||
},
|
||||
};
|
||||
|
||||
let didSuceed = false;
|
||||
|
||||
/**
|
||||
* If a timeout is provided, we will cancel the graph if it takes too long - but we need a way to clear the timeout
|
||||
* if the graph completes or errors before the timeout.
|
||||
*/
|
||||
let timeoutId: number | null = null;
|
||||
const _clearTimeout = () => {
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
// There's a bit of a catch-22 here: we need to set the cancelGraph callback before we enqueue the graph, but we
|
||||
// can't set it until we have the batch_id from the enqueue request. So we'll set a dummy function here and update
|
||||
// it later.
|
||||
let cancelGraph: () => void = () => {
|
||||
this.log.warn('cancelGraph called before cancelGraph is set');
|
||||
};
|
||||
|
||||
const resultPromise = new Promise<ImageDTO>((resolve, reject) => {
|
||||
const invocationCompleteHandler = async (event: S['InvocationCompleteEvent']) => {
|
||||
// Ignore events that are not for this graph
|
||||
if (event.origin !== origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore events that are not from the output node
|
||||
if (event.invocation_source_id !== outputNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get here, the event is for the correct graph and output node.
|
||||
|
||||
// Clear the timeout and socket listeners
|
||||
_clearTimeout();
|
||||
clearListeners();
|
||||
|
||||
// The result must be an image output
|
||||
const { result } = event;
|
||||
if (result.type !== 'image_output') {
|
||||
reject(new Error(`Graph output node did not return an image output, got: ${result}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the result image DTO
|
||||
const getImageDTOResult = await withResultAsync(() => getImageDTO(result.image.image_name));
|
||||
if (getImageDTOResult.isErr()) {
|
||||
reject(getImageDTOResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
didSuceed = true;
|
||||
|
||||
// Ok!
|
||||
resolve(getImageDTOResult.value);
|
||||
};
|
||||
|
||||
const queueItemStatusChangedHandler = (event: S['QueueItemStatusChangedEvent']) => {
|
||||
// Ignore events that are not for this graph
|
||||
if (event.origin !== origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore events where the status is pending or in progress - no need to do anything for these
|
||||
if (event.status === 'pending' || event.status === 'in_progress') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.status === 'completed') {
|
||||
/**
|
||||
* The invocation_complete event should have been received before the queue item completed event, and the
|
||||
* event listeners are cleared in the invocation_complete handler. If we get here, it means we never got
|
||||
* the completion event for the output node! This should is a fail case.
|
||||
*
|
||||
* TODO(psyche): In the unexpected case where events are received out of order, this logic doesn't do what
|
||||
* we expect. If we got a queue item completed event before the output node completion event, we'd erroneously
|
||||
* triggers this error.
|
||||
*
|
||||
* For now, we'll just log a warning instead of rejecting the promise. This should be super rare anyways.
|
||||
*/
|
||||
// reject(new Error('Queue item completed without output node completion event'));
|
||||
this.log.warn('Queue item completed without output node completion event');
|
||||
return;
|
||||
}
|
||||
|
||||
// event.status is 'failed', 'canceled' - something has gone awry
|
||||
_clearTimeout();
|
||||
clearListeners();
|
||||
|
||||
if (event.status === 'failed') {
|
||||
// We expect the event to have error details, but technically it's possible that it doesn't
|
||||
const { error_type, error_message, error_traceback } = event;
|
||||
if (error_type && error_message && error_traceback) {
|
||||
reject(new QueueError(error_type, error_message, error_traceback));
|
||||
} else {
|
||||
reject(new Error('Queue item failed, but no error details were provided'));
|
||||
}
|
||||
} else {
|
||||
// event.status is 'canceled'
|
||||
reject(new Error('Graph canceled'));
|
||||
}
|
||||
};
|
||||
|
||||
// We are ready to enqueue the graph
|
||||
const enqueueRequest = this.store.dispatch(
|
||||
queueApi.endpoints.enqueueBatch.initiate(batch, {
|
||||
// Use the same cache key for all enqueueBatch requests, so that all consumers of this query get the same status
|
||||
// updates.
|
||||
...enqueueMutationFixedCacheKeyOptions,
|
||||
// We do not need RTK to track this request in the store
|
||||
track: false,
|
||||
})
|
||||
);
|
||||
|
||||
// Enqueue the graph and get the batch_id, updating the cancel graph callack. We need to do this in a .then() block
|
||||
// instead of awaiting the promise to avoid await-ing in a promise executor. Also need to catch any errors.
|
||||
enqueueRequest
|
||||
.unwrap()
|
||||
.then((data) => {
|
||||
// The `batch_id` should _always_ be present - the OpenAPI schema from which the types are generated is incorrect.
|
||||
// TODO(psyche): Fix the OpenAPI schema.
|
||||
const batch_id = data.batch.batch_id;
|
||||
assert(batch_id, 'Enqueue result is missing batch_id');
|
||||
cancelGraph = () => {
|
||||
this.store.dispatch(
|
||||
queueApi.endpoints.cancelByBatchIds.initiate({ batch_ids: [batch_id] }, { track: false })
|
||||
);
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.manager.socket.on('invocation_complete', invocationCompleteHandler);
|
||||
this.manager.socket.on('queue_item_status_changed', queueItemStatusChangedHandler);
|
||||
|
||||
const clearListeners = () => {
|
||||
this.manager.socket.off('invocation_complete', invocationCompleteHandler);
|
||||
this.manager.socket.off('queue_item_status_changed', queueItemStatusChangedHandler);
|
||||
};
|
||||
|
||||
if (timeout) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
if (didSuceed) {
|
||||
// If we already succeeded, we don't need to do anything
|
||||
return;
|
||||
}
|
||||
this.log.trace('Graph canceled by timeout');
|
||||
clearListeners();
|
||||
cancelGraph();
|
||||
reject(new Error('Graph timed out'));
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
if (didSuceed) {
|
||||
// If we already succeeded, we don't need to do anything
|
||||
return;
|
||||
}
|
||||
this.log.trace('Graph canceled by signal');
|
||||
_clearTimeout();
|
||||
clearListeners();
|
||||
cancelGraph();
|
||||
reject(new Error('Graph canceled'));
|
||||
});
|
||||
}
|
||||
const { output } = await runGraph({
|
||||
dependencies,
|
||||
...arg,
|
||||
});
|
||||
|
||||
return resultPromise;
|
||||
// Extract the image from the result - we expect a single image
|
||||
const imageDTO = await this.getImageDTOFromResult(output);
|
||||
|
||||
return imageDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to extract ImageDTO from graph execution result.
|
||||
* Expects the result to be an ImageOutput.
|
||||
*/
|
||||
private getImageDTOFromResult = async (result: S['GraphExecutionState']['results'][string]): Promise<ImageDTO> => {
|
||||
// Validate that the result is an ImageOutput using zod schema
|
||||
const parseResult = zImageOutput.safeParse(result);
|
||||
if (!parseResult.success) {
|
||||
throw new Error(`Graph output is not a valid ImageOutput. Got: ${JSON.stringify(result)}`);
|
||||
}
|
||||
|
||||
const imageOutput = parseResult.data;
|
||||
return await getImageDTO(imageOutput.image.image_name);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,12 @@ import { noop } from 'es-toolkit/compat';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
|
||||
import { fitRectToGrid, getKonvaNodeDebugAttrs, getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import {
|
||||
areStageAttrsGonnaExplode,
|
||||
fitRectToGrid,
|
||||
getKonvaNodeDebugAttrs,
|
||||
getPrefixedId,
|
||||
} from 'features/controlLayers/konva/util';
|
||||
import { selectBboxOverlay } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { selectModel } from 'features/controlLayers/store/paramsSlice';
|
||||
import { selectBbox } from 'features/controlLayers/store/selectors';
|
||||
@@ -253,6 +258,9 @@ export class CanvasBboxToolModule extends CanvasModuleBase {
|
||||
}
|
||||
|
||||
const stageAttrs = this.manager.stage.$stageAttrs.get();
|
||||
if (areStageAttrsGonnaExplode(stageAttrs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.konva.overlayRect.setAttrs({
|
||||
x: -stageAttrs.x / stageAttrs.scale,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $focusedRegion } from 'common/hooks/focus';
|
||||
import { getFocusedRegion } from 'common/hooks/focus';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
|
||||
@@ -62,7 +62,7 @@ export class CanvasMoveToolModule extends CanvasModuleBase {
|
||||
};
|
||||
|
||||
nudge = (nudgeKey: NudgeKey) => {
|
||||
if ($focusedRegion.get() !== 'canvas') {
|
||||
if (getFocusedRegion() !== 'canvas') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CoordinateWithPressure,
|
||||
Rect,
|
||||
RgbaColor,
|
||||
StageAttrs,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import type Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
@@ -320,6 +321,7 @@ export const downloadBlob = (blob: Blob, fileName: string) => {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -769,3 +771,7 @@ export const roundRect = (rect: Rect): Rect => {
|
||||
height: Math.round(rect.height),
|
||||
};
|
||||
};
|
||||
|
||||
export const areStageAttrsGonnaExplode = (stageAttrs: StageAttrs): boolean => {
|
||||
return stageAttrs.height === 0 || stageAttrs.width === 0 || stageAttrs.scale === 0;
|
||||
};
|
||||
|
||||
@@ -73,6 +73,10 @@ type CanvasSettingsState = {
|
||||
* Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
|
||||
*/
|
||||
pressureSensitivity: boolean;
|
||||
/**
|
||||
* Whether to show the rule of thirds composition guide overlay on the canvas.
|
||||
*/
|
||||
ruleOfThirds: boolean;
|
||||
};
|
||||
|
||||
const initialState: CanvasSettingsState = {
|
||||
@@ -92,6 +96,7 @@ const initialState: CanvasSettingsState = {
|
||||
isolatedStagingPreview: true,
|
||||
isolatedLayerPreview: true,
|
||||
pressureSensitivity: true,
|
||||
ruleOfThirds: false,
|
||||
};
|
||||
|
||||
export const canvasSettingsSlice = createSlice({
|
||||
@@ -146,6 +151,9 @@ export const canvasSettingsSlice = createSlice({
|
||||
settingsPressureSensitivityToggled: (state) => {
|
||||
state.pressureSensitivity = !state.pressureSensitivity;
|
||||
},
|
||||
settingsRuleOfThirdsToggled: (state) => {
|
||||
state.ruleOfThirds = !state.ruleOfThirds;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -166,6 +174,7 @@ export const {
|
||||
settingsIsolatedStagingPreviewToggled,
|
||||
settingsIsolatedLayerPreviewToggled,
|
||||
settingsPressureSensitivityToggled,
|
||||
settingsRuleOfThirdsToggled,
|
||||
} = canvasSettingsSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
@@ -199,3 +208,4 @@ export const selectShowProgressOnCanvas = createCanvasSettingsSelector(
|
||||
export const selectIsolatedStagingPreview = createCanvasSettingsSelector((settings) => settings.isolatedStagingPreview);
|
||||
export const selectIsolatedLayerPreview = createCanvasSettingsSelector((settings) => settings.isolatedLayerPreview);
|
||||
export const selectPressureSensitivity = createCanvasSettingsSelector((settings) => settings.pressureSensitivity);
|
||||
export const selectRuleOfThirds = createCanvasSettingsSelector((settings) => settings.ruleOfThirds);
|
||||
|
||||
@@ -833,6 +833,8 @@ export const canvasSlice = createSlice({
|
||||
}
|
||||
|
||||
if (isIPAdapterConfig(referenceImage.config)) {
|
||||
referenceImage.config.model = zModelIdentifierField.parse(modelConfig);
|
||||
|
||||
// Ensure that the IP Adapter model is compatible with the CLIP Vision model
|
||||
if (referenceImage.config.model?.base === 'flux') {
|
||||
referenceImage.config.clipVisionModel = 'ViT-L';
|
||||
@@ -1537,6 +1539,16 @@ export const canvasSlice = createSlice({
|
||||
break;
|
||||
}
|
||||
},
|
||||
allNonRasterLayersIsHiddenToggled: (state) => {
|
||||
const hasVisibleNonRasterLayers =
|
||||
!state.controlLayers.isHidden || !state.inpaintMasks.isHidden || !state.regionalGuidance.isHidden;
|
||||
|
||||
const shouldHide = hasVisibleNonRasterLayers;
|
||||
|
||||
state.controlLayers.isHidden = shouldHide;
|
||||
state.inpaintMasks.isHidden = shouldHide;
|
||||
state.regionalGuidance.isHidden = shouldHide;
|
||||
},
|
||||
allEntitiesDeleted: (state) => {
|
||||
// Deleting all entities is equivalent to resetting the state for each entity type
|
||||
const initialState = getInitialCanvasState();
|
||||
@@ -1646,6 +1658,7 @@ export const {
|
||||
entitiesReordered,
|
||||
allEntitiesDeleted,
|
||||
allEntitiesOfTypeIsHiddenToggled,
|
||||
allNonRasterLayersIsHiddenToggled,
|
||||
// bbox
|
||||
bboxChangedFromCanvas,
|
||||
bboxScaledWidthChanged,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { canvasReset } from 'features/controlLayers/store/actions';
|
||||
|
||||
type CanvasStagingAreaState = {
|
||||
@@ -20,26 +19,16 @@ export const canvasSessionSlice = createSlice({
|
||||
name: 'canvasSession',
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
generateSessionIdCreated: {
|
||||
reducer: (state, action: PayloadAction<{ id: string }>) => {
|
||||
const { id } = action.payload;
|
||||
state.generateSessionId = id;
|
||||
},
|
||||
prepare: () => ({
|
||||
payload: { id: getPrefixedId('generate') },
|
||||
}),
|
||||
generateSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => {
|
||||
const { id } = action.payload;
|
||||
state.generateSessionId = id;
|
||||
},
|
||||
generateSessionReset: (state) => {
|
||||
state.generateSessionId = null;
|
||||
},
|
||||
canvasSessionIdCreated: {
|
||||
reducer: (state, action: PayloadAction<{ id: string }>) => {
|
||||
const { id } = action.payload;
|
||||
state.canvasSessionId = id;
|
||||
},
|
||||
prepare: () => ({
|
||||
payload: { id: getPrefixedId('canvas') },
|
||||
}),
|
||||
canvasSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => {
|
||||
const { id } = action.payload;
|
||||
state.canvasSessionId = id;
|
||||
},
|
||||
canvasSessionReset: (state) => {
|
||||
state.canvasSessionId = null;
|
||||
@@ -52,7 +41,7 @@ export const canvasSessionSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { generateSessionIdCreated, generateSessionReset, canvasSessionIdCreated, canvasSessionReset } =
|
||||
export const { generateSessionIdChanged, generateSessionReset, canvasSessionIdChanged, canvasSessionReset } =
|
||||
canvasSessionSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getReferenceImageState,
|
||||
imageDTOToImageWithDims,
|
||||
initialChatGPT4oReferenceImage,
|
||||
initialFluxKontextReferenceImage,
|
||||
initialFLUXRedux,
|
||||
initialIPAdapter,
|
||||
} from './util';
|
||||
@@ -121,6 +122,16 @@ export const refImagesSlice = createSlice({
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.config.model.base === 'flux-kontext') {
|
||||
// Switching to flux-kontext ref image
|
||||
entity.config = {
|
||||
...initialFluxKontextReferenceImage,
|
||||
image: entity.config.image,
|
||||
model: entity.config.model,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.config.model.type === 'flux_redux') {
|
||||
// Switching to flux_redux
|
||||
entity.config = {
|
||||
@@ -211,6 +222,14 @@ export const refImagesSlice = createSlice({
|
||||
}
|
||||
state.selectedEntityId = id;
|
||||
},
|
||||
refImageIsEnabledToggled: (state, action: PayloadActionWithId) => {
|
||||
const { id } = action.payload;
|
||||
const entity = selectRefImageEntity(state, id);
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
entity.isEnabled = !entity.isEnabled;
|
||||
},
|
||||
refImagesReset: () => getInitialRefImagesState(),
|
||||
},
|
||||
extraReducers(builder) {
|
||||
@@ -232,6 +251,7 @@ export const {
|
||||
refImageIPAdapterWeightChanged,
|
||||
refImageIPAdapterBeginEndStepPctChanged,
|
||||
refImageFLUXReduxImageInfluenceChanged,
|
||||
refImageIsEnabledToggled,
|
||||
} = refImagesSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
|
||||
@@ -406,3 +406,11 @@ export const selectIsCanvasEmpty = createCanvasSelector(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Selects whether all non-raster layer categories (control layers, inpaint masks, regional guidance) are hidden.
|
||||
* This is used to determine the state of the toggle button that shows/hides all non-raster layers.
|
||||
*/
|
||||
export const selectNonRasterLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => {
|
||||
return canvas.controlLayers.isHidden && canvas.inpaintMasks.isHidden && canvas.regionalGuidance.isHidden;
|
||||
});
|
||||
|
||||
@@ -302,6 +302,7 @@ const zCanvasEntityBase = z.object({
|
||||
|
||||
const zRefImageState = z.object({
|
||||
id: zId,
|
||||
isEnabled: z.boolean().default(true),
|
||||
// This should be named `referenceImage` but we need to keep it as `ipAdapter` for backwards compatibility
|
||||
config: z.discriminatedUnion('type', [
|
||||
zIPAdapterConfig,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
RgbColor,
|
||||
T2IAdapterConfig,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import type { ImageField } from 'features/nodes/types/common';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
@@ -59,6 +60,8 @@ export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO)
|
||||
height,
|
||||
});
|
||||
|
||||
export const imageDTOToImageField = ({ image_name }: ImageDTO): ImageField => ({ image_name });
|
||||
|
||||
const DEFAULT_RG_MASK_FILL_COLORS: RgbColor[] = [
|
||||
{ r: 121, g: 157, b: 219 }, // rgb(121, 157, 219)
|
||||
{ r: 131, g: 214, b: 131 }, // rgb(131, 214, 131)
|
||||
@@ -129,6 +132,7 @@ export const initialControlLoRA: ControlLoRAConfig = {
|
||||
export const getReferenceImageState = (id: string, overrides?: PartialDeep<RefImageState>): RefImageState => {
|
||||
const entityState: RefImageState = {
|
||||
id,
|
||||
isEnabled: true,
|
||||
config: deepClone(initialIPAdapter),
|
||||
};
|
||||
merge(entityState, overrides);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ConfirmationAlertDialog, Divider, Flex, FormControl, FormLabel, Switch, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage';
|
||||
import { useDeleteImageModalApi, useDeleteImageModalState } from 'features/deleteImageModal/store/state';
|
||||
import { selectSystemShouldConfirmOnDelete, setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
|
||||
@@ -22,7 +21,7 @@ export const DeleteImageModal = memo(() => {
|
||||
|
||||
return (
|
||||
<ConfirmationAlertDialog
|
||||
title={`${t('gallery.deleteImage', { count: state.image_names.length })}2`}
|
||||
title={`${t('gallery.deleteImage', { count: state.image_names.length })}`}
|
||||
isOpen={state.isOpen}
|
||||
onClose={api.close}
|
||||
cancelButtonText={t('common.cancel')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { getStore, useAppStore } from 'app/store/nanostores/store';
|
||||
import type { AppDispatch, AppGetState, RootState } from 'app/store/store';
|
||||
import type { AppDispatch, AppStore, RootState } from 'app/store/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { forEach, intersection, some } from 'es-toolkit/compat';
|
||||
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
|
||||
import {
|
||||
@@ -52,14 +52,15 @@ const getInitialState = (): DeleteImagesModalState => ({
|
||||
|
||||
const $deleteModalState = atom<DeleteImagesModalState>(getInitialState());
|
||||
|
||||
const deleteImagesWithDialog = async (image_names: string[]): Promise<void> => {
|
||||
const { getState, dispatch } = getStore();
|
||||
const deleteImagesWithDialog = async (image_names: string[], store: AppStore): Promise<void> => {
|
||||
const { getState } = store;
|
||||
const imageUsage = getImageUsageFromImageNames(image_names, getState());
|
||||
const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState());
|
||||
|
||||
if (!shouldConfirmOnDelete && !isAnyImageInUse(imageUsage)) {
|
||||
// If we don't need to confirm and the images are not in use, delete them directly
|
||||
await handleDeletions(image_names, dispatch, getState);
|
||||
await handleDeletions(image_names, store);
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -74,8 +75,9 @@ const deleteImagesWithDialog = async (image_names: string[]): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletions = async (image_names: string[], dispatch: AppDispatch, getState: AppGetState) => {
|
||||
const handleDeletions = async (image_names: string[], store: AppStore) => {
|
||||
try {
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap();
|
||||
|
||||
@@ -96,9 +98,9 @@ const handleDeletions = async (image_names: string[], dispatch: AppDispatch, get
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => {
|
||||
const confirmDeletion = async (store: AppStore) => {
|
||||
const state = $deleteModalState.get();
|
||||
await handleDeletions(state.image_names, dispatch, getState);
|
||||
await handleDeletions(state.image_names, store);
|
||||
state.resolve?.();
|
||||
closeSilently();
|
||||
};
|
||||
@@ -119,16 +121,16 @@ export const useDeleteImageModalState = () => {
|
||||
};
|
||||
|
||||
export const useDeleteImageModalApi = () => {
|
||||
const { dispatch, getState } = useAppStore();
|
||||
const store = useAppStore();
|
||||
const api = useMemo(
|
||||
() => ({
|
||||
delete: deleteImagesWithDialog,
|
||||
confirm: () => confirmDeletion(dispatch, getState),
|
||||
delete: (image_names: string[]) => deleteImagesWithDialog(image_names, store),
|
||||
confirm: () => confirmDeletion(store),
|
||||
cancel: cancelDeletion,
|
||||
close: closeSilently,
|
||||
getUsageSummary: getImageUsageSummary,
|
||||
}),
|
||||
[dispatch, getState]
|
||||
[store]
|
||||
);
|
||||
|
||||
return api;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Image } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { singleImageDndSource } from 'features/dnd/dnd';
|
||||
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
|
||||
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Heading } from '@invoke-ai/ui-library';
|
||||
import { getStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { $focusedRegion } from 'common/hooks/focus';
|
||||
import { getFocusedRegion } from 'common/hooks/focus';
|
||||
import { useClientSideUpload } from 'common/hooks/useClientSideUpload';
|
||||
import { setFileToPaste } from 'features/controlLayers/components/CanvasPasteModal';
|
||||
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
|
||||
@@ -88,7 +88,7 @@ export const FullscreenDropzone = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusedRegion = $focusedRegion.get();
|
||||
const focusedRegion = getFocusedRegion();
|
||||
|
||||
// While on the canvas tab and when pasting a single image, canvas may want to create a new layer. Let it handle
|
||||
// the paste event.
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field';
|
||||
import { expandPrompt } from 'features/prompt/PromptExpansion/expand';
|
||||
import { promptExpansionApi } from 'features/prompt/PromptExpansion/state';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
@@ -515,23 +517,48 @@ export const removeImageFromBoardDndTarget: DndTarget<
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Prompt Generation From Image
|
||||
const _promptGenerationFromImage = buildTypeAndKey('prompt-generation-from-image');
|
||||
export type PromptGenerationFromImageDndTargetData = DndData<
|
||||
typeof _promptGenerationFromImage.type,
|
||||
typeof _promptGenerationFromImage.key,
|
||||
void
|
||||
>;
|
||||
export const promptGenerationFromImageDndTarget: DndTarget<
|
||||
PromptGenerationFromImageDndTargetData,
|
||||
SingleImageDndSourceData
|
||||
> = {
|
||||
..._promptGenerationFromImage,
|
||||
typeGuard: buildTypeGuard(_promptGenerationFromImage.key),
|
||||
getData: buildGetData(_promptGenerationFromImage.key, _promptGenerationFromImage.type),
|
||||
isValid: ({ sourceData }) => {
|
||||
if (singleImageDndSource.typeGuard(sourceData)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handler: ({ sourceData, dispatch, getState }) => {
|
||||
const { imageDTO } = sourceData.payload;
|
||||
promptExpansionApi.setPending(imageDTO);
|
||||
expandPrompt({ dispatch, getState, imageDTO });
|
||||
},
|
||||
};
|
||||
//#endregion
|
||||
|
||||
export const dndTargets = [
|
||||
// Single Image
|
||||
setGlobalReferenceImageDndTarget,
|
||||
addGlobalReferenceImageDndTarget,
|
||||
setRegionalGuidanceReferenceImageDndTarget,
|
||||
setUpscaleInitialImageDndTarget,
|
||||
setNodeImageFieldImageDndTarget,
|
||||
addImagesToNodeImageFieldCollectionDndTarget,
|
||||
setComparisonImageDndTarget,
|
||||
newCanvasEntityFromImageDndTarget,
|
||||
newCanvasFromImageDndTarget,
|
||||
replaceCanvasEntityObjectsWithImageDndTarget,
|
||||
addImageToBoardDndTarget,
|
||||
removeImageFromBoardDndTarget,
|
||||
newCanvasFromImageDndTarget,
|
||||
addGlobalReferenceImageDndTarget,
|
||||
// Single or Multiple Image
|
||||
addImageToBoardDndTarget,
|
||||
removeImageFromBoardDndTarget,
|
||||
addImagesToNodeImageFieldCollectionDndTarget,
|
||||
promptGenerationFromImageDndTarget,
|
||||
] as const;
|
||||
|
||||
export type AnyDndTarget = (typeof dndTargets)[number];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { debounce } from 'es-toolkit/compat';
|
||||
import {
|
||||
isErrorChanged,
|
||||
|
||||
@@ -21,10 +21,10 @@ const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 };
|
||||
export const BoardsPanel = memo(() => {
|
||||
const boardSearchText = useAppSelector(selectBoardSearchText);
|
||||
const searchDisclosure = useDisclosure(!!boardSearchText);
|
||||
const { _$rightPanelApi } = useAutoLayoutContext();
|
||||
const gridviewPanelApi = useStore(_$rightPanelApi);
|
||||
const { tab } = useAutoLayoutContext();
|
||||
const collapsibleApi = useCollapsibleGridviewPanel(
|
||||
gridviewPanelApi,
|
||||
tab,
|
||||
'right',
|
||||
BOARDS_PANEL_ID,
|
||||
'vertical',
|
||||
BOARD_PANEL_DEFAULT_HEIGHT_PX,
|
||||
@@ -45,7 +45,7 @@ export const BoardsPanel = memo(() => {
|
||||
}, [boardSearchText.length, searchDisclosure, collapsibleApi, dispatch]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" w="full" h="full" p={2}>
|
||||
<Flex flexDir="column" w="full" h="full">
|
||||
<Flex alignItems="center" justifyContent="space-between" w="full">
|
||||
<Flex flexGrow={1} flexBasis={0}>
|
||||
<Button
|
||||
|
||||
@@ -32,10 +32,10 @@ const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery
|
||||
export const GalleryPanel = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { _$rightPanelApi } = useAutoLayoutContext();
|
||||
const gridviewPanelApi = useStore(_$rightPanelApi);
|
||||
const { tab } = useAutoLayoutContext();
|
||||
const collapsibleApi = useCollapsibleGridviewPanel(
|
||||
gridviewPanelApi,
|
||||
tab,
|
||||
'right',
|
||||
GALLERY_PANEL_ID,
|
||||
'vertical',
|
||||
GALLERY_PANEL_DEFAULT_HEIGHT_PX,
|
||||
@@ -67,7 +67,7 @@ export const GalleryPanel = memo(() => {
|
||||
const boardName = useBoardName(selectedBoardId);
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full" minH={0} p={2}>
|
||||
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full" minH={0}>
|
||||
<Flex gap={2} fontSize="sm" alignItems="center" w="full">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -126,4 +126,4 @@ export const GalleryPanel = memo(() => {
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
GalleryPanel.displayName = 'Gallery';
|
||||
GalleryPanel.displayName = 'GalleryPanel';
|
||||
|
||||
@@ -60,9 +60,12 @@ const getImageDTOFromMap = (target: Node): ImageDTO | undefined => {
|
||||
* @param imageDTO The image DTO to register the context menu for.
|
||||
* @param targetRef The ref of the target element that should trigger the context menu.
|
||||
*/
|
||||
export const useImageContextMenu = (imageDTO: ImageDTO, ref: RefObject<HTMLElement>) => {
|
||||
export const useImageContextMenu = (imageDTO: ImageDTO, ref: RefObject<HTMLElement> | (HTMLElement | null)) => {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (ref === null) {
|
||||
return;
|
||||
}
|
||||
const el = ref instanceof HTMLElement ? ref : ref.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
|
||||
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { newCanvasFromImage } from 'features/imageActions/actions';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFileBold, PiPlusBold } from 'react-icons/pi';
|
||||
@@ -20,7 +21,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
|
||||
const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => {
|
||||
const { dispatch, getState } = store;
|
||||
await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState });
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
@@ -31,7 +32,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
|
||||
const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => {
|
||||
const { dispatch, getState } = store;
|
||||
await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState });
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
@@ -42,7 +43,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
|
||||
const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => {
|
||||
const { dispatch, getState } = store;
|
||||
await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState });
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
@@ -53,7 +54,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
|
||||
const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => {
|
||||
const { dispatch, getState } = store;
|
||||
await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState });
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
|
||||
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
|
||||
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
@@ -7,7 +7,8 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { sentImageToCanvas } from 'features/gallery/store/actions';
|
||||
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
@@ -23,7 +24,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
|
||||
const { dispatch, getState } = store;
|
||||
createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
|
||||
dispatch(sentImageToCanvas());
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
@@ -35,7 +36,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
|
||||
const { dispatch, getState } = store;
|
||||
createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
|
||||
dispatch(sentImageToCanvas());
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
@@ -47,7 +48,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
|
||||
const { dispatch, getState } = store;
|
||||
createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState });
|
||||
dispatch(sentImageToCanvas());
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
@@ -59,7 +60,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
|
||||
const { dispatch, getState } = store;
|
||||
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState });
|
||||
dispatch(sentImageToCanvas());
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
@@ -71,7 +72,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
|
||||
const { dispatch, getState } = store;
|
||||
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState });
|
||||
dispatch(sentImageToCanvas());
|
||||
dispatch(setActiveTab('canvas'));
|
||||
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { IconMenuItem } from 'common/components/IconMenuItem';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowsOutBold } from 'react-icons/pi';
|
||||
@@ -13,7 +15,7 @@ export const ImageMenuItemOpenInViewer = memo(() => {
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(imageToCompareChanged(null));
|
||||
dispatch(imageSelected(imageDTO.image_name));
|
||||
// TODO: figure out how to select the closest image viewer...
|
||||
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
|
||||
}, [dispatch, imageDTO]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
|
||||
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
|
||||
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { expandPrompt } from 'features/prompt/PromptExpansion/expand';
|
||||
import { promptExpansionApi } from 'features/prompt/PromptExpansion/state';
|
||||
import { selectAllowPromptExpansion } from 'features/system/store/configSlice';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiTextTBold } from 'react-icons/pi';
|
||||
|
||||
export const ImageMenuItemUseForPromptGeneration = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const { dispatch, getState } = useAppStore();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const { isPending } = useStore(promptExpansionApi.$state);
|
||||
const isPromptExpansionEnabled = useAppSelector(selectAllowPromptExpansion);
|
||||
|
||||
const handleUseForPromptGeneration = useCallback(() => {
|
||||
promptExpansionApi.setPending(imageDTO);
|
||||
expandPrompt({ dispatch, getState, imageDTO });
|
||||
toast({
|
||||
id: 'PROMPT_GENERATION_STARTED',
|
||||
title: t('toast.promptGenerationStarted'),
|
||||
status: 'info',
|
||||
});
|
||||
}, [dispatch, getState, imageDTO, t]);
|
||||
|
||||
if (!isPromptExpansionEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
icon={<PiTextTBold />}
|
||||
onClickCapture={handleUseForPromptGeneration}
|
||||
id="use-for-prompt-generation"
|
||||
isDisabled={isPending}
|
||||
>
|
||||
{t('gallery.useForPromptGeneration')}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
ImageMenuItemUseForPromptGeneration.displayName = 'ImageMenuItemUseForPromptGeneration';
|
||||
@@ -14,6 +14,7 @@ import { ImageMenuItemSelectForCompare } from 'features/gallery/components/Image
|
||||
import { ImageMenuItemSendToUpscale } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale';
|
||||
import { ImageMenuItemStarUnstar } from 'features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar';
|
||||
import { ImageMenuItemUseAsRefImage } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage';
|
||||
import { ImageMenuItemUseForPromptGeneration } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration';
|
||||
import { ImageDTOContextProvider } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { memo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -38,6 +39,7 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) =
|
||||
<ImageMenuItemMetadataRecallActions />
|
||||
<MenuDivider />
|
||||
<ImageMenuItemSendToUpscale />
|
||||
<ImageMenuItemUseForPromptGeneration />
|
||||
<ImageMenuItemUseAsRefImage />
|
||||
<ImageMenuItemNewCanvasFromImageSubMenu />
|
||||
<ImageMenuItemNewLayerFromImageSubMenu />
|
||||
|
||||
@@ -3,9 +3,8 @@ import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import type { AppDispatch, AppGetState } from 'app/store/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { uniq } from 'es-toolkit';
|
||||
import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
|
||||
import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage';
|
||||
@@ -16,16 +15,22 @@ import { firefoxDndFix } from 'features/dnd/util';
|
||||
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
|
||||
import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons';
|
||||
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
||||
import { selectListImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import {
|
||||
selectListImageNamesQueryArgs,
|
||||
selectSelectedBoardId,
|
||||
selectSelection,
|
||||
} from 'features/gallery/store/gallerySelectors';
|
||||
import { imageToCompareChanged, selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import type { MouseEvent, MouseEventHandler } from 'react';
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { PiImageBold } from 'react-icons/pi';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
const GALLERY_IMAGE_CLASS = 'gallery-image';
|
||||
|
||||
const galleryImageContainerSX = {
|
||||
containerType: 'inline-size',
|
||||
w: 'full',
|
||||
@@ -38,7 +43,7 @@ const galleryImageContainerSX = {
|
||||
'&[data-is-dragging=true]': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
'.gallery-image': {
|
||||
[`.${GALLERY_IMAGE_CLASS}`]: {
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
webkitUserSelect: 'none',
|
||||
@@ -134,13 +139,12 @@ const buildOnClick =
|
||||
|
||||
export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
const store = useAppStore();
|
||||
const autoLayoutContext = useAutoLayoutContext();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPreviewState, setDragPreviewState] = useState<
|
||||
DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null
|
||||
>(null);
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const dndId = useId();
|
||||
// Must use callback ref - else chakra's Image fallback prop will break the ref & dnd
|
||||
const [element, ref] = useState<HTMLImageElement | null>(null);
|
||||
const selectIsSelectedForCompare = useMemo(
|
||||
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare === imageDTO.image_name),
|
||||
[imageDTO.image_name]
|
||||
@@ -153,7 +157,6 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
const isSelected = useAppSelector(selectIsSelected);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
@@ -162,16 +165,14 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
draggable({
|
||||
element,
|
||||
getInitialData: () => {
|
||||
const { gallery } = store.getState();
|
||||
const selection = selectSelection(store.getState());
|
||||
const boardId = selectSelectedBoardId(store.getState());
|
||||
// When we have multiple images selected, and the dragged image is part of the selection, initiate a
|
||||
// multi-image drag.
|
||||
if (
|
||||
gallery.selection.length > 1 &&
|
||||
gallery.selection.find((image_name) => image_name === imageDTO.image_name) !== undefined
|
||||
) {
|
||||
if (selection.length > 1 && selection.includes(imageDTO.image_name)) {
|
||||
return multipleImageDndSource.getData({
|
||||
image_names: gallery.selection,
|
||||
board_id: gallery.selectedBoardId,
|
||||
image_names: selection,
|
||||
board_id: boardId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,7 +222,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [imageDTO, store, dndId]);
|
||||
}, [element, imageDTO, store]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -237,19 +238,19 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
|
||||
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(() => {
|
||||
store.dispatch(imageToCompareChanged(null));
|
||||
autoLayoutContext.focusPanel(VIEWER_PANEL_ID);
|
||||
}, [autoLayoutContext, store]);
|
||||
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
|
||||
}, [store]);
|
||||
|
||||
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);
|
||||
|
||||
useImageContextMenu(imageDTO, ref);
|
||||
useImageContextMenu(imageDTO, element);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={galleryImageContainerSX} data-testid={dataTestId} data-is-dragging={isDragging}>
|
||||
<Flex
|
||||
role="button"
|
||||
className="gallery-image"
|
||||
className={GALLERY_IMAGE_CLASS}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { DndImageIcon } from 'features/dnd/DndImageIcon';
|
||||
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -14,14 +14,13 @@ type Props = {
|
||||
|
||||
export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { focusPanel } = useAutoLayoutContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(imageToCompareChanged(null));
|
||||
dispatch(imageSelected(imageDTO.image_name));
|
||||
focusPanel(VIEWER_PANEL_ID);
|
||||
}, [dispatch, focusPanel, imageDTO]);
|
||||
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
|
||||
}, [dispatch, imageDTO]);
|
||||
|
||||
return (
|
||||
<DndImageIcon
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user