Compare commits

...

96 Commits

Author SHA1 Message Date
Mary Hipp
2faa3e72a3 Merge remote-tracking branch 'origin/main' into maryhipp/video-poc 2025-08-28 10:07:36 -04:00
Mary Hipp
e670866585 add translations 2025-08-28 10:06:45 -04:00
Mary Hipp
afa43053c4 add credit estimate for video generation 2025-08-28 10:01:40 -04:00
Mary Hipp
8cb3f31fe1 add label for starting image field 2025-08-28 10:01:30 -04:00
Mary Hipp
0875674ad5 default resolution to 1080p 2025-08-28 10:01:18 -04:00
Mary Hipp
f7a4aceb1f use first video model if none selected 2025-08-28 09:39:01 -04:00
psychedelicious
a123a7f29f fix(ui): hide unused queue actions menu item category 2025-08-28 16:20:48 +10:00
psychedelicious
2fb4275b3c chore(ui): lint 2025-08-28 16:20:31 +10:00
psychedelicious
56f2b7d9e9 fix(ui): more video translations 2025-08-28 16:17:36 +10:00
psychedelicious
a155ce5954 fix(ui): make ctx menu star label not refer to iamges 2025-08-28 16:15:22 +10:00
psychedelicious
fc4e1366ed fix(ui): make ctx menu download tooltip not refer to iamges 2025-08-28 16:11:17 +10:00
psychedelicious
4416301395 feat(ui): remove unimplemented context menu items for video 2025-08-28 16:10:57 +10:00
psychedelicious
eb6087eab0 fix(ui): metadata viewer translations 2025-08-28 16:03:38 +10:00
psychedelicious
fc3c789df3 chore(ui): lint 2025-08-28 15:57:00 +10:00
psychedelicious
6ec1d2e368 fix(ui): do not highlight starting frame image in red when it is not required 2025-08-28 15:55:01 +10:00
psychedelicious
094c0c15ed feat(ui): tweak video settings padding 2025-08-28 15:47:31 +10:00
psychedelicious
108b55d7f2 feat(ui): add border around starting frame image 2025-08-28 15:47:18 +10:00
psychedelicious
5765fce6b9 fix(ui): graph builder check for veo 2025-08-28 15:41:40 +10:00
psychedelicious
9285f24c39 tweak(ui): nav bar divider not so bright 2025-08-28 15:25:13 +10:00
psychedelicious
97b6abf50f fix(ui): tab hotkeys for video 2025-08-28 15:24:26 +10:00
psychedelicious
8951bdeef3 chore(ui): lint 2025-08-28 14:33:02 +10:00
psychedelicious
9a7a66cf5a chore: ruff 2025-08-28 14:22:06 +10:00
Mary Hipp
b39a16aa84 studio init action for video tab 2025-08-28 14:12:35 +10:00
Mary Hipp
1dd79bddb3 launchpad cleanup 2025-08-28 14:12:35 +10:00
Mary Hipp
45bfaf1f39 fix view on large screens, restore auth for screen capture 2025-08-28 14:12:35 +10:00
Mary Hipp
1100b3eb4f rearrange image | video | asset for boards 2025-08-28 14:12:35 +10:00
Mary Hipp
1e0bed9a89 add option for video upsell, rearrange navigation bar and gallery tabs 2025-08-28 14:12:35 +10:00
Mary Hipp
09fb5ab04f hide video features if video is disabled 2025-08-28 14:12:35 +10:00
psychedelicious
cc2b0ae8c1 feat(ui): delete confirmation for videos 2025-08-28 14:12:35 +10:00
psychedelicious
f3690bec7d feat(ui): metadata recall for videos 2025-08-28 14:12:35 +10:00
psychedelicious
4ebaa8982a fix(ui): do not store whole model config in state 2025-08-28 14:12:35 +10:00
psychedelicious
7d98dd31af fix(ui): do not change canvas bbox on video model change 2025-08-28 14:12:35 +10:00
psychedelicious
bd4af9c04c feat(ui): use correct model config object in video graph builders 2025-08-28 14:12:35 +10:00
psychedelicious
201769711b feat(ui): add selector to get model config for current video model 2025-08-28 14:12:35 +10:00
psychedelicious
f5ac1df7d2 feat(ui): simplify and consolidate video capture logic 2025-08-28 14:12:34 +10:00
psychedelicious
f8c940bc11 fix(ui): rebase conflict 2025-08-28 14:12:34 +10:00
Mary Hipp
e818027dc6 lint again 2025-08-28 14:12:34 +10:00
Mary Hipp
5c80a2a8ab lint 2025-08-28 14:12:34 +10:00
Mary Hipp
d6a3e7b7a4 use context to track video ref so that toolbar can also save current frame 2025-08-28 14:12:34 +10:00
Mary Hipp
01563eab3b add save frame functionality 2025-08-28 14:12:34 +10:00
Mary Hipp
438658c89b add video_count and asset_count to boards UI 2025-08-28 14:12:34 +10:00
Mary Hipp
f5f4527b36 add asset_count to BoardDTO and split it out from image count 2025-08-28 14:12:34 +10:00
Mary Hipp
b4b54d4d2a add video_count to boardDTO 2025-08-28 14:12:34 +10:00
Mary Hipp
32e2d176de video metadata support 2025-08-28 14:12:34 +10:00
Mary Hipp
2b688ed855 split out video aspect/ratio into its own components 2025-08-28 14:12:14 +10:00
Mary Hipp
b52a885e74 updates for new model type 2025-08-28 14:11:03 +10:00
Mary Hipp
38b43228d6 add UI support for new model type Video 2025-08-28 14:11:03 +10:00
Mary Hipp
447754318b add Video as new model type 2025-08-28 14:09:55 +10:00
psychedelicious
160a9b78cb docs(ui): add note about visual jank in gallery 2025-08-28 14:09:55 +10:00
psychedelicious
770b1a9f7a fix(ui): use correct placeholder for vidoes 2025-08-28 14:09:55 +10:00
psychedelicious
550c32c2cd fix(ui): locate in gallery, galleryview when selecting image/video 2025-08-28 14:09:55 +10:00
psychedelicious
e46842bea2 chore(ui): lint 2025-08-28 14:09:55 +10:00
psychedelicious
cda78f3c01 chore(ui): dpdm 2025-08-28 14:09:44 +10:00
psychedelicious
e75bc159bd feat(ui): video dnd 2025-08-28 14:09:44 +10:00
psychedelicious
b5b75f3e8d fix(ui): generate tab graph builder 2025-08-28 14:09:44 +10:00
psychedelicious
42ab6890f5 fix(ui): iterations works for video models 2025-08-28 14:09:44 +10:00
psychedelicious
2d2ba257c6 fix(ui): missing tranlsation 2025-08-28 14:09:44 +10:00
psychedelicious
f1d15c9a3d fix(ui): fetching imageDTO for video 2025-08-28 14:09:44 +10:00
psychedelicious
9937e35b02 tidy(ui): remove unused VideoAtPosition component 2025-08-28 14:09:44 +10:00
psychedelicious
7d5b49e70a feat(ui): simpler layout for video player 2025-08-28 14:09:44 +10:00
Mary Hipp
1cc9583335 fix video styling 2025-08-28 14:09:05 +10:00
Mary Hipp
517cd0880e add runway back as a model and allow runway and veo3 to live together in peace and harmony 2025-08-28 14:09:05 +10:00
Mary Hipp
3dcc290498 add runway to backend 2025-08-28 14:05:47 +10:00
Mary Hipp
2e3ece2f02 update redux selection to have a list of images and/or videos, update image viewer to show either image or video depending on what is selected 2025-08-28 14:05:47 +10:00
Mary Hipp
b7a141d13c lint 2025-08-28 14:05:47 +10:00
Mary Hipp
8a8705c10b tsc 2025-08-28 14:05:47 +10:00
Mary Hipp
7714c3234b lint the dang thing 2025-08-28 14:05:09 +10:00
psychedelicious
d70d9cf88b gallery 2025-08-28 14:04:47 +10:00
psychedelicious
0dddd02107 Revert "feat(ui): consolidated gallery (wip)"
This reverts commit 12b70bca67.
2025-08-28 14:04:47 +10:00
Mary Hipp
f9c55c2773 add videos to change board modal 2025-08-28 14:04:47 +10:00
Mary Hipp
dc652b68c7 add resolution as a generation setting 2025-08-28 14:04:47 +10:00
Mary Hipp
bd2009681d replace runway with veo, build out veo3 model support 2025-08-28 14:04:47 +10:00
Mary Hipp
7ea5bd5344 add Veo3 model support to backend 2025-08-28 14:02:05 +10:00
psychedelicious
5c9c8f0110 feat(ui): consolidated gallery (wip) 2025-08-28 14:02:05 +10:00
psychedelicious
d611454a77 feat(ui): gallery optimistic updates for video 2025-08-28 14:02:05 +10:00
psychedelicious
fe008439d4 fix(ui): panel names on video tab 2025-08-28 14:02:05 +10:00
Mary Hipp
3098ae7259 stubbing out change board functionality 2025-08-28 14:02:05 +10:00
Mary Hipp
4094f1f5e8 hook up starring, unstarring, and deleting single videos (no multiselect yet), adapt context menus to work for both images and videos and start on video context menu 2025-08-28 14:02:05 +10:00
Mary Hipp
f871ac0554 add readiness logic to video tab 2025-08-28 14:02:05 +10:00
psychedelicious
bb71b21d15 feat(ui): more video stuff 2025-08-28 14:01:32 +10:00
psychedelicious
a170537c27 feat(ui): fiddle w/ video stuff 2025-08-28 14:01:32 +10:00
psychedelicious
dba78743a4 feat(ui): fiddle w/ video stuff 2025-08-28 14:01:32 +10:00
psychedelicious
65b3d5ea15 chore: ruff 2025-08-28 14:01:22 +10:00
psychedelicious
5651bbe7c9 feat(nodes): update VideoField & VideoOutput 2025-08-28 14:01:22 +10:00
psychedelicious
e393bc5d6c feat(ui): add dnd target for video start frame 2025-08-28 14:01:22 +10:00
Mary Hipp
b1ac69fd35 add duration and aspect ratio to video settings 2025-08-28 14:01:22 +10:00
Mary Hipp
66d8b86149 integrating video into gallery - thinking maybe a new category of image would make more senes 2025-08-28 13:59:01 +10:00
Mary Hipp
d0e12c31f7 add noop video router 2025-08-28 13:59:01 +10:00
Mary Hipp
7fb396d86e add video models 2025-08-28 13:58:50 +10:00
Mary Hipp
26f94e6ddf combine nodes that generate and save videos 2025-08-28 13:58:50 +10:00
Mary Hipp
d6651ba9a7 build out adhoc video saving graph 2025-08-28 13:58:50 +10:00
Mary Hipp
af8ad1de3d push up updates for VideoField 2025-08-28 13:58:22 +10:00
Mary Hipp
efb54151fd update VideoField 2025-08-28 13:58:22 +10:00
Mary Hipp
8d5e2b0816 split out RunwayVideoOutput from VideoOutput 2025-08-28 13:58:21 +10:00
Mary Hipp
0224fab631 rough rough POC of video tab 2025-08-28 13:58:21 +10:00
Mary Hipp
e6ef3a78d6 video_output support 2025-08-28 13:58:21 +10:00
204 changed files with 8271 additions and 1196 deletions

55
getItemsPerRow.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Calculate how many images fit in a row based on the current grid layout.
*
* TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value
* changes. Cache this calculation.
*/
export const getItemsPerRow = (rootEl: HTMLDivElement): number => {
// Start from root and find virtuoso grid elements
const gridElement = rootEl.querySelector('.virtuoso-grid-list');
if (!gridElement) {
return 0;
}
const firstGridItem = gridElement.querySelector('.virtuoso-grid-item');
if (!firstGridItem) {
return 0;
}
const itemRect = firstGridItem.getBoundingClientRect();
const containerRect = gridElement.getBoundingClientRect();
// Get the computed gap from CSS
const gridStyle = window.getComputedStyle(gridElement);
const gapValue = gridStyle.gap;
const gap = parseFloat(gapValue);
if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) {
return 0;
}
/**
* You might be tempted to just do some simple math like:
* const itemsPerRow = Math.floor(containerRect.width / itemRect.width);
*
* But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases.
*
* Instead, we use a more robust approach that iteratively calculates how many items fit in the row.
*/
let itemsPerRow = 0;
let spaceUsed = 0;
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
// this, without the possibility of accidentally adding an extra column.
while (spaceUsed + itemRect.width <= containerRect.width + 1) {
itemsPerRow++; // Increment the number of items
spaceUsed += itemRect.width; // Add image size to the used space
if (spaceUsed + gap <= containerRect.width) {
spaceUsed += gap; // Add gap size to the used space after each item except after the last item
}
}
return Math.max(1, itemsPerRow);
};

View File

@@ -0,0 +1,39 @@
from fastapi import Body, HTTPException
from fastapi.routing import APIRouter
from invokeai.app.services.videos_common import AddVideosToBoardResult, RemoveVideosFromBoardResult
board_videos_router = APIRouter(prefix="/v1/board_videos", tags=["boards"])
@board_videos_router.post(
"/batch",
operation_id="add_videos_to_board",
responses={
201: {"description": "Videos were added to board successfully"},
},
status_code=201,
response_model=AddVideosToBoardResult,
)
async def add_videos_to_board(
board_id: str = Body(description="The id of the board to add to"),
video_ids: list[str] = Body(description="The ids of the videos to add", embed=True),
) -> AddVideosToBoardResult:
"""Adds a list of videos to a board"""
raise HTTPException(status_code=501, detail="Not implemented")
@board_videos_router.post(
"/batch/delete",
operation_id="remove_videos_from_board",
responses={
201: {"description": "Videos were removed from board successfully"},
},
status_code=201,
response_model=RemoveVideosFromBoardResult,
)
async def remove_videos_from_board(
video_ids: list[str] = Body(description="The ids of the videos to remove", embed=True),
) -> RemoveVideosFromBoardResult:
"""Removes a list of videos from their board, if they had one"""
raise HTTPException(status_code=501, detail="Not implemented")

View File

@@ -0,0 +1,119 @@
from typing import Optional
from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.videos_common import (
DeleteVideosResult,
StarredVideosResult,
UnstarredVideosResult,
VideoDTO,
VideoIdsResult,
VideoRecordChanges,
)
videos_router = APIRouter(prefix="/v1/videos", tags=["videos"])
@videos_router.patch(
"/i/{video_id}",
operation_id="update_video",
response_model=VideoDTO,
)
async def update_video(
video_id: str = Path(description="The id of the video to update"),
video_changes: VideoRecordChanges = Body(description="The changes to apply to the video"),
) -> VideoDTO:
"""Updates a video"""
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.get(
"/i/{video_id}",
operation_id="get_video_dto",
response_model=VideoDTO,
)
async def get_video_dto(
video_id: str = Path(description="The id of the video to get"),
) -> VideoDTO:
"""Gets a video's DTO"""
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.post("/delete", operation_id="delete_videos_from_list", response_model=DeleteVideosResult)
async def delete_videos_from_list(
video_ids: list[str] = Body(description="The list of ids of videos to delete", embed=True),
) -> DeleteVideosResult:
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.post("/star", operation_id="star_videos_in_list", response_model=StarredVideosResult)
async def star_videos_in_list(
video_ids: list[str] = Body(description="The list of ids of videos to star", embed=True),
) -> StarredVideosResult:
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.post("/unstar", operation_id="unstar_videos_in_list", response_model=UnstarredVideosResult)
async def unstar_videos_in_list(
video_ids: list[str] = Body(description="The list of ids of videos to unstar", embed=True),
) -> UnstarredVideosResult:
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.delete("/uncategorized", operation_id="delete_uncategorized_videos", response_model=DeleteVideosResult)
async def delete_uncategorized_videos() -> DeleteVideosResult:
"""Deletes all videos that are uncategorized"""
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.get("/", operation_id="list_video_dtos", response_model=OffsetPaginatedResults[VideoDTO])
async def list_video_dtos(
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate videos."),
board_id: Optional[str] = Query(
default=None,
description="The board id to filter by. Use 'none' to find videos without a board.",
),
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of videos per page"),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"),
search_term: Optional[str] = Query(default=None, description="The term to search for"),
) -> OffsetPaginatedResults[VideoDTO]:
"""Lists video DTOs"""
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.get("/ids", operation_id="get_video_ids")
async def get_video_ids(
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate videos."),
board_id: Optional[str] = Query(
default=None,
description="The board id to filter by. Use 'none' to find videos without a board.",
),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"),
search_term: Optional[str] = Query(default=None, description="The term to search for"),
) -> VideoIdsResult:
"""Gets ordered list of video ids with metadata for optimistic updates"""
raise HTTPException(status_code=501, detail="Not implemented")
@videos_router.post(
"/videos_by_ids",
operation_id="get_videos_by_ids",
responses={200: {"model": list[VideoDTO]}},
)
async def get_videos_by_ids(
video_ids: list[str] = Body(embed=True, description="Object containing list of video ids to fetch DTOs for"),
) -> list[VideoDTO]:
"""Gets video DTOs for the specified video ids. Maintains order of input ids."""
raise HTTPException(status_code=501, detail="Not implemented")

View File

@@ -18,6 +18,7 @@ from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles
from invokeai.app.api.routers import (
app_info,
board_images,
board_videos,
boards,
client_state,
download_queue,
@@ -27,6 +28,7 @@ from invokeai.app.api.routers import (
session_queue,
style_presets,
utilities,
videos,
workflows,
)
from invokeai.app.api.sockets import SocketIO
@@ -125,8 +127,10 @@ app.include_router(utilities.utilities_router, prefix="/api")
app.include_router(model_manager.model_manager_router, prefix="/api")
app.include_router(download_queue.download_queue_router, prefix="/api")
app.include_router(images.images_router, prefix="/api")
app.include_router(videos.videos_router, prefix="/api")
app.include_router(boards.boards_router, prefix="/api")
app.include_router(board_images.board_images_router, prefix="/api")
app.include_router(board_videos.board_videos_router, prefix="/api")
app.include_router(model_relationships.model_relationships_router, prefix="/api")
app.include_router(app_info.app_router, prefix="/api")
app.include_router(session_queue.session_queue_router, prefix="/api")

View File

@@ -66,11 +66,14 @@ class UIType(str, Enum, metaclass=MetaEnum):
ChatGPT4oModel = "ChatGPT4oModelField"
Gemini2_5Model = "Gemini2_5ModelField"
FluxKontextModel = "FluxKontextModelField"
Veo3Model = "Veo3ModelField"
RunwayModel = "RunwayModelField"
# endregion
# region Misc Field Types
Scheduler = "SchedulerField"
Any = "AnyField"
Video = "VideoField"
# endregion
# region Internal Field Types
@@ -225,6 +228,12 @@ class ImageField(BaseModel):
image_name: str = Field(description="The name of the image")
class VideoField(BaseModel):
"""A video primitive field"""
video_id: str = Field(description="The id of the video")
class BoardField(BaseModel):
"""A board primitive field"""

View File

@@ -27,6 +27,7 @@ from invokeai.app.invocations.fields import (
SD3ConditioningField,
TensorField,
UIComponent,
VideoField,
)
from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.shared.invocation_context import InvocationContext
@@ -287,6 +288,30 @@ class ImageCollectionInvocation(BaseInvocation):
return ImageCollectionOutput(collection=self.collection)
# endregion
# region Video
@invocation_output("video_output")
class VideoOutput(BaseInvocationOutput):
"""Base class for nodes that output a video"""
video: VideoField = OutputField(description="The output video")
width: int = OutputField(description="The width of the video in pixels")
height: int = OutputField(description="The height of the video in pixels")
duration_seconds: float = OutputField(description="The duration of the video in seconds")
@classmethod
def build(cls, video_id: str, width: int, height: int, duration_seconds: float) -> "VideoOutput":
return cls(
video=VideoField(video_id=video_id),
width=width,
height=height,
duration_seconds=duration_seconds,
)
# endregion
# region DenoiseMask

View File

@@ -49,3 +49,11 @@ class BoardImageRecordStorageBase(ABC):
) -> int:
"""Gets the number of images for a board."""
pass
@abstractmethod
def get_asset_count_for_board(
self,
board_id: str,
) -> int:
"""Gets the number of assets for a board."""
pass

View File

@@ -3,6 +3,8 @@ from typing import Optional, cast
from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
from invokeai.app.services.image_records.image_records_common import (
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
ImageCategory,
ImageRecord,
deserialize_image_record,
@@ -151,15 +153,38 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
def get_image_count_for_board(self, board_id: str) -> int:
with self._db.transaction() as cursor:
# Convert the enum values to unique list of strings
category_strings = [c.value for c in set(IMAGE_CATEGORIES)]
# Create the correct length of placeholders
placeholders = ",".join("?" * len(category_strings))
cursor.execute(
"""--sql
f"""--sql
SELECT COUNT(*)
FROM board_images
INNER JOIN images ON board_images.image_name = images.image_name
WHERE images.is_intermediate = FALSE
WHERE images.is_intermediate = FALSE AND images.image_category IN ( {placeholders} )
AND board_images.board_id = ?;
""",
(board_id,),
(board_id),
)
count = cast(int, cursor.fetchone()[0])
return count
def get_asset_count_for_board(self, board_id: str) -> int:
with self._db.transaction() as cursor:
# Convert the enum values to unique list of strings
category_strings = [c.value for c in set(ASSETS_CATEGORIES)]
# Create the correct length of placeholders
placeholders = ",".join("?" * len(category_strings))
cursor.execute(
f"""--sql
SELECT COUNT(*)
FROM board_images
INNER JOIN images ON board_images.image_name = images.image_name
WHERE images.is_intermediate = FALSE AND images.image_category IN ( {placeholders} )
AND board_images.board_id = ?;
""",
(board_id),
)
count = cast(int, cursor.fetchone()[0])
return count

View File

@@ -12,12 +12,20 @@ class BoardDTO(BoardRecord):
"""The URL of the thumbnail of the most recent image in the board."""
image_count: int = Field(description="The number of images in the board.")
"""The number of images in the board."""
asset_count: int = Field(description="The number of assets in the board.")
"""The number of assets in the board."""
video_count: int = Field(description="The number of videos in the board.")
"""The number of videos in the board."""
def board_record_to_dto(board_record: BoardRecord, cover_image_name: Optional[str], image_count: int) -> BoardDTO:
def board_record_to_dto(
board_record: BoardRecord, cover_image_name: Optional[str], image_count: int, asset_count: int, video_count: int
) -> BoardDTO:
"""Converts a board record to a board DTO."""
return BoardDTO(
**board_record.model_dump(exclude={"cover_image_name"}),
cover_image_name=cover_image_name,
image_count=image_count,
asset_count=asset_count,
video_count=video_count,
)

View File

@@ -17,7 +17,7 @@ class BoardService(BoardServiceABC):
board_name: str,
) -> BoardDTO:
board_record = self.__invoker.services.board_records.save(board_name)
return board_record_to_dto(board_record, None, 0)
return board_record_to_dto(board_record, None, 0, 0, 0)
def get_dto(self, board_id: str) -> BoardDTO:
board_record = self.__invoker.services.board_records.get(board_id)
@@ -27,7 +27,9 @@ class BoardService(BoardServiceABC):
else:
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(board_id)
return board_record_to_dto(board_record, cover_image_name, image_count)
asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(board_id)
video_count = 0 # noop for OSS
return board_record_to_dto(board_record, cover_image_name, image_count, asset_count, video_count)
def update(
self,
@@ -42,7 +44,9 @@ class BoardService(BoardServiceABC):
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(board_id)
return board_record_to_dto(board_record, cover_image_name, image_count)
asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(board_id)
video_count = 0 # noop for OSS
return board_record_to_dto(board_record, cover_image_name, image_count, asset_count, video_count)
def delete(self, board_id: str) -> None:
self.__invoker.services.board_records.delete(board_id)
@@ -67,7 +71,9 @@ class BoardService(BoardServiceABC):
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id)
board_dtos.append(board_record_to_dto(r, cover_image_name, image_count))
asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id)
video_count = 0 # noop for OSS
board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, video_count))
return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos))
@@ -84,6 +90,8 @@ class BoardService(BoardServiceABC):
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id)
board_dtos.append(board_record_to_dto(r, cover_image_name, image_count))
asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id)
video_count = 0 # noop for OSS
board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, video_count))
return board_dtos

View File

@@ -58,6 +58,15 @@ class ImageCategory(str, Enum, metaclass=MetaEnum):
"""OTHER: The image is some other type of image with a specialized purpose. To be used by external nodes."""
IMAGE_CATEGORIES: list[ImageCategory] = [ImageCategory.GENERAL]
ASSETS_CATEGORIES: list[ImageCategory] = [
ImageCategory.CONTROL,
ImageCategory.MASK,
ImageCategory.USER,
ImageCategory.OTHER,
]
class InvalidImageCategoryException(ValueError):
"""Raised when a provided value is not a valid ImageCategory.

View File

@@ -0,0 +1,179 @@
import datetime
from typing import Optional, Union
from pydantic import BaseModel, Field, StrictBool, StrictStr
from invokeai.app.util.misc import get_iso_timestamp
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
VIDEO_DTO_COLS = ", ".join(
[
"videos." + c
for c in [
"video_id",
"width",
"height",
"session_id",
"node_id",
"is_intermediate",
"created_at",
"updated_at",
"deleted_at",
"starred",
]
]
)
class VideoRecord(BaseModelExcludeNull):
"""Deserialized video record without metadata."""
video_id: str = Field(description="The unique id of the video.")
"""The unique id of the video."""
width: int = Field(description="The width of the video in px.")
"""The actual width of the video in px. This may be different from the width in metadata."""
height: int = Field(description="The height of the video in px.")
"""The actual height of the video in px. This may be different from the height in metadata."""
created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the video.")
"""The created timestamp of the video."""
updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the video.")
"""The updated timestamp of the video."""
deleted_at: Optional[Union[datetime.datetime, str]] = Field(
default=None, description="The deleted timestamp of the video."
)
"""The deleted timestamp of the video."""
is_intermediate: bool = Field(description="Whether this is an intermediate video.")
"""Whether this is an intermediate video."""
session_id: Optional[str] = Field(
default=None,
description="The session ID that generated this video, if it is a generated video.",
)
"""The session ID that generated this video, if it is a generated video."""
node_id: Optional[str] = Field(
default=None,
description="The node ID that generated this video, if it is a generated video.",
)
"""The node ID that generated this video, if it is a generated video."""
starred: bool = Field(description="Whether this video is starred.")
"""Whether this video is starred."""
class VideoRecordChanges(BaseModelExcludeNull):
"""A set of changes to apply to a video record.
Only limited changes are valid:
- `session_id`: change the session associated with a video
- `is_intermediate`: change the video's `is_intermediate` flag
- `starred`: change whether the video is starred
"""
session_id: Optional[StrictStr] = Field(
default=None,
description="The video's new session ID.",
)
"""The video's new session ID."""
is_intermediate: Optional[StrictBool] = Field(default=None, description="The video's new `is_intermediate` flag.")
"""The video's new `is_intermediate` flag."""
starred: Optional[StrictBool] = Field(default=None, description="The video's new `starred` state")
"""The video's new `starred` state."""
def deserialize_video_record(video_dict: dict) -> VideoRecord:
"""Deserializes a video record."""
# Retrieve all the values, setting "reasonable" defaults if they are not present.
video_id = video_dict.get("video_id", "unknown")
width = video_dict.get("width", 0)
height = video_dict.get("height", 0)
session_id = video_dict.get("session_id", None)
node_id = video_dict.get("node_id", None)
created_at = video_dict.get("created_at", get_iso_timestamp())
updated_at = video_dict.get("updated_at", get_iso_timestamp())
deleted_at = video_dict.get("deleted_at", get_iso_timestamp())
is_intermediate = video_dict.get("is_intermediate", False)
starred = video_dict.get("starred", False)
return VideoRecord(
video_id=video_id,
width=width,
height=height,
session_id=session_id,
node_id=node_id,
created_at=created_at,
updated_at=updated_at,
deleted_at=deleted_at,
is_intermediate=is_intermediate,
starred=starred,
)
class VideoCollectionCounts(BaseModel):
starred_count: int = Field(description="The number of starred videos in the collection.")
unstarred_count: int = Field(description="The number of unstarred videos in the collection.")
class VideoIdsResult(BaseModel):
"""Response containing ordered video ids with metadata for optimistic updates."""
video_ids: list[str] = Field(description="Ordered list of video ids")
starred_count: int = Field(description="Number of starred videos (when starred_first=True)")
total_count: int = Field(description="Total number of videos matching the query")
class VideoUrlsDTO(BaseModelExcludeNull):
"""The URLs for an image and its thumbnail."""
video_id: str = Field(description="The unique id of the video.")
"""The unique id of the video."""
video_url: str = Field(description="The URL of the video.")
"""The URL of the video."""
thumbnail_url: str = Field(description="The URL of the video's thumbnail.")
"""The URL of the video's thumbnail."""
class VideoDTO(VideoRecord, VideoUrlsDTO):
"""Deserialized video record, enriched for the frontend."""
board_id: Optional[str] = Field(
default=None, description="The id of the board the image belongs to, if one exists."
)
"""The id of the board the image belongs to, if one exists."""
def video_record_to_dto(
video_record: VideoRecord,
video_url: str,
thumbnail_url: str,
board_id: Optional[str],
) -> VideoDTO:
"""Converts a video record to a video DTO."""
return VideoDTO(
**video_record.model_dump(),
video_url=video_url,
thumbnail_url=thumbnail_url,
board_id=board_id,
)
class ResultWithAffectedBoards(BaseModel):
affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation")
class DeleteVideosResult(ResultWithAffectedBoards):
deleted_videos: list[str] = Field(description="The ids of the videos that were deleted")
class StarredVideosResult(ResultWithAffectedBoards):
starred_videos: list[str] = Field(description="The ids of the videos that were starred")
class UnstarredVideosResult(ResultWithAffectedBoards):
unstarred_videos: list[str] = Field(description="The ids of the videos that were unstarred")
class AddVideosToBoardResult(ResultWithAffectedBoards):
added_videos: list[str] = Field(description="The video ids that were added to the board")
class RemoveVideosFromBoardResult(ResultWithAffectedBoards):
removed_videos: list[str] = Field(description="The video ids that were removed from their board")

View File

@@ -492,6 +492,15 @@ class MainConfigBase(ABC, BaseModel):
variant: AnyVariant = ModelVariantType.Normal
class VideoConfigBase(ABC, BaseModel):
type: Literal[ModelType.Video] = ModelType.Video
trigger_phrases: Optional[set[str]] = Field(description="Set of trigger phrases for this model", default=None)
default_settings: Optional[MainModelDefaultSettings] = Field(
description="Default settings for this model", default=None
)
variant: AnyVariant = ModelVariantType.Normal
class MainCheckpointConfig(CheckpointConfigBase, MainConfigBase, LegacyProbeMixin, ModelConfigBase):
"""Model config for main checkpoint models."""
@@ -649,6 +658,21 @@ class ApiModelConfig(MainConfigBase, ModelConfigBase):
raise NotImplementedError("API models are not parsed from disk.")
class VideoApiModelConfig(VideoConfigBase, ModelConfigBase):
"""Model config for API-based video models."""
format: Literal[ModelFormat.Api] = ModelFormat.Api
@classmethod
def matches(cls, mod: ModelOnDisk) -> bool:
# API models are not stored on disk, so we can't match them.
return False
@classmethod
def parse(cls, mod: ModelOnDisk) -> dict[str, Any]:
raise NotImplementedError("API models are not parsed from disk.")
def get_model_discriminator_value(v: Any) -> str:
"""
Computes the discriminator value for a model config.
@@ -718,6 +742,7 @@ AnyModelConfig = Annotated[
Annotated[FluxReduxConfig, FluxReduxConfig.get_tag()],
Annotated[LlavaOnevisionConfig, LlavaOnevisionConfig.get_tag()],
Annotated[ApiModelConfig, ApiModelConfig.get_tag()],
Annotated[VideoApiModelConfig, VideoApiModelConfig.get_tag()],
],
Discriminator(get_model_discriminator_value),
]

View File

@@ -31,6 +31,8 @@ class BaseModelType(str, Enum):
Gemini2_5 = "gemini-2.5"
ChatGPT4o = "chatgpt-4o"
FluxKontext = "flux-kontext"
Veo3 = "veo3"
Runway = "runway"
class ModelType(str, Enum):
@@ -52,6 +54,7 @@ class ModelType(str, Enum):
SigLIP = "siglip"
FluxRedux = "flux_redux"
LlavaOnevision = "llava_onevision"
Video = "video"
class SubModelType(str, Enum):

View File

@@ -69,6 +69,7 @@
"linkify-react": "^4.3.1",
"linkifyjs": "^4.3.1",
"lru-cache": "^11.1.0",
"media-chrome": "^4.13.0",
"mtwist": "^1.0.2",
"nanoid": "^5.1.5",
"nanostores": "^1.0.1",
@@ -87,6 +88,7 @@
"react-hotkeys-hook": "4.5.0",
"react-i18next": "^15.5.3",
"react-icons": "^5.5.0",
"react-player": "^3.3.1",
"react-redux": "9.2.0",
"react-resizable-panels": "^3.0.3",
"react-textarea-autosize": "^8.5.9",

View File

@@ -98,6 +98,9 @@ importers:
lru-cache:
specifier: ^11.1.0
version: 11.1.0
media-chrome:
specifier: ^4.13.0
version: 4.13.0(react@18.3.1)
mtwist:
specifier: ^1.0.2
version: 1.0.2
@@ -152,6 +155,9 @@ importers:
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@18.3.1)
react-player:
specifier: ^3.3.1
version: 3.3.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-redux:
specifier: 9.2.0
version: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1)
@@ -937,6 +943,31 @@ packages:
'@microsoft/tsdoc@0.15.1':
resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==}
'@mux/mux-data-google-ima@0.2.8':
resolution: {integrity: sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==}
'@mux/mux-player-react@3.5.3':
resolution: {integrity: sha512-f0McZbIXYDkzecFwhhkf0JgEInPnsOClgBqBhkdhRlLRdrAzMATib+D3Di3rPkRHNH7rc/WWORvSxgJz6m6zkA==}
peerDependencies:
'@types/react': ^17.0.0 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0
'@types/react-dom': '*'
react: ^17.0.2 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0
react-dom: ^17.0.2 || ^17.0.2-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@mux/mux-player@3.5.3':
resolution: {integrity: sha512-uXKFXbdtioAi+clSVfD60Rw4r7OvA62u2jV6aar9loW9qMsmKv8LU+8uaIaWQjyAORp6E0S37GOVjo72T6O2eQ==}
'@mux/mux-video@0.26.1':
resolution: {integrity: sha512-gkMdBAgNlB4+krANZHkQFzYWjWeNsJz69y1/hnPtmNQnpvW+O7oc71OffcZrbblyibSxWMQ6MQpYmBVjXlp6sA==}
'@mux/playback-core@0.30.1':
resolution: {integrity: sha512-rnO1NE9xHDyzbAkmE6ygJYcD7cyyMt7xXqWTykxlceaoSXLjUqgp42HDio7Lcidto4x/O4FIa7ztjV2aCBCXgQ==}
'@nanostores/react@0.7.3':
resolution: {integrity: sha512-/XuLAMENRu/Q71biW4AZ4qmU070vkZgiQ28gaTSNRPm2SZF5zGAR81zPE1MaMB4SeOp6ZTst92NBaG75XSspNg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -1453,6 +1484,9 @@ packages:
typescript:
optional: true
'@svta/common-media-library@0.12.4':
resolution: {integrity: sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==}
'@swc/core-darwin-arm64@1.12.9':
resolution: {integrity: sha512-GACFEp4nD6V+TZNR2JwbMZRHB+Yyvp14FrcmB6UCUYmhuNWjkxi+CLnEvdbuiKyQYv0zA+TRpCHZ+whEs6gwfA==}
engines: {node: '>=10'}
@@ -1707,6 +1741,12 @@ packages:
resolution: {integrity: sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vercel/edge@1.2.2':
resolution: {integrity: sha512-1+y+f6rk0Yc9ss9bRDgz/gdpLimwoRteKHhrcgHvEpjbP1nyT3ByqEMWm2BTcpIO5UtDmIFXc8zdq4LR190PDA==}
'@vimeo/player@2.29.0':
resolution: {integrity: sha512-9JjvjeqUndb9otCCFd0/+2ESsLk7VkDE6sxOBy9iy2ukezuQbplVRi+g9g59yAurKofbmTi/KcKxBGO/22zWRw==}
'@vitejs/plugin-react-swc@3.10.2':
resolution: {integrity: sha512-xD3Rdvrt5LgANug7WekBn1KhcvLn1H3jNBfJRL3reeOIua/WnZOEV5qi5qIBq5T8R0jUDmRtxuvk4bPhzGHDWw==}
peerDependencies:
@@ -1959,6 +1999,15 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
bcp-47-match@2.0.3:
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
bcp-47-normalize@2.3.0:
resolution: {integrity: sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==}
bcp-47@2.1.0:
resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==}
better-opn@3.0.2:
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
engines: {node: '>=12.0.0'}
@@ -2014,6 +2063,14 @@ packages:
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
castable-video@1.1.10:
resolution: {integrity: sha512-/T1I0A4VG769wTEZ8gWuy1Crn9saAfRTd1UYTb8xbOPlN78+zOi/1nU2dD5koNkfE5VWvgabkIqrGKmyNXOjSQ==}
ce-la-react@0.3.1:
resolution: {integrity: sha512-g0YwpZDPIwTwFumGTzNHcgJA6VhFfFCJkSNdUdC04br2UfU+56JDrJrJva3FZ7MToB4NDHAFBiPE/PZdNl1mQA==}
peerDependencies:
react: '>=17.0.0'
chai@5.2.0:
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
engines: {node: '>=12'}
@@ -2064,12 +2121,18 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
cloudflare-video-element@1.3.3:
resolution: {integrity: sha512-qrHzwLmUhisoIuEoKc7iBbdzBNj2Pi7ThHslU/9U/6PY9DEvo4mh/U+w7OVuzXT9ks7ZXfARvDBfPAaMGF/hIg==}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
codem-isoboxer@0.3.10:
resolution: {integrity: sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2139,6 +2202,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
custom-media-element@1.4.5:
resolution: {integrity: sha512-cjrsQufETwxjvwZbYbKBCJNvmQ2++G9AvT45zDi7NXL9k2PdVcs2h0jQz96J6G4TMKRCcEsoJ+QTgQD00Igtjw==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
@@ -2177,6 +2243,12 @@ packages:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
dash-video-element@0.1.6:
resolution: {integrity: sha512-4gHShaQjcFv6diX5EzB6qAdUGKlIUGGZY8J8yp2pQkWqR0jX4c6plYy0cFraN7mr0DZINe8ujDN1fssDYxJjcg==}
dashjs@5.0.3:
resolution: {integrity: sha512-TXndNnCUjFjF2nYBxDVba+hWRpVkadkQ8flLp7kHkem+5+wZTfRShJCnVkPUosmjS0YPE9fVNLbYPJxHBeQZvA==}
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -2751,9 +2823,18 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hls-video-element@1.5.6:
resolution: {integrity: sha512-KPdvSR+oBJPiCVb+m6pd2mn3rJEjNbaK8pGhSkxFI2pmyvZIeTVQrPbEO9PT/juwXHwhvCoKJnNxAuFwJG9H5A==}
hls.js@1.6.9:
resolution: {integrity: sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -2792,6 +2873,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
@@ -2803,6 +2887,9 @@ packages:
resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==}
engines: {node: '>=8'}
imsc@1.1.5:
resolution: {integrity: sha512-V8je+CGkcvGhgl2C1GlhqFFiUOIEdwXbXLiu1Fcubvvbo+g9inauqT3l0pNYXGoLPBj3jxtZz9t+wCopMkwadQ==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
@@ -2825,6 +2912,12 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2860,6 +2953,9 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
@@ -3064,6 +3160,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lie@3.1.1:
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -3088,6 +3187,9 @@ packages:
resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
engines: {node: '>=14'}
localforage@1.10.0:
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -3154,6 +3256,15 @@ packages:
mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
media-chrome@4.11.1:
resolution: {integrity: sha512-+2niDc4qOwlpFAjwxg1OaizK/zKV6y7QqGm4nBFEVlSaG0ZBgOmfc4IXAPiirZqAlZGaFFUaMqCl1SpGU0/naA==}
media-chrome@4.13.0:
resolution: {integrity: sha512-DfX/Hwxjae/tEHjr1tVnV/6XDFHriMXI1ev8Ji4Z/YwXnqMhNfRtvNsMjefnQK5pkMS/9hC+jmdS+VDWZfsSIw==}
media-tracks@0.3.3:
resolution: {integrity: sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@@ -3219,6 +3330,12 @@ packages:
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
mux-embed@5.11.0:
resolution: {integrity: sha512-uczzXVraqMRmyYmpGh2zthTmBKvvc5D5yaVKQRgGhFOnF7E4nkhqNkdkQc4C0WTPzdqdPl5OtCelNWMF4tg5RQ==}
mux-embed@5.9.0:
resolution: {integrity: sha512-wmunL3uoPhma/tWy8PrDPZkvJpXvSFBwbD3KkC4PG8Ztjfb1X3hRJwGUAQyRz7z99b/ovLm2UTTitrkvStjH4w==}
nano-css@5.6.2:
resolution: {integrity: sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==}
peerDependencies:
@@ -3243,6 +3360,9 @@ packages:
resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==}
engines: {node: ^20.0.0 || >=22.0.0}
native-promise-only@0.8.1:
resolution: {integrity: sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -3433,6 +3553,9 @@ packages:
pkg-types@2.2.0:
resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==}
player.style@0.1.9:
resolution: {integrity: sha512-aFmIhHMrnAP8YliFYFMnRw+5AlHqBvnqWy4vHGo2kFxlC+XjmTXqgg62qSxlE8ubAY83c0ViEZGYglSJi6mGCA==}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -3575,6 +3698,13 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-player@3.3.1:
resolution: {integrity: sha512-wE/xLloneXZ1keelFCaNeIFVNUp4/7YoUjfHjwF945aQzsbDKiIB0LQuCchGL+la0Y1IybxnR0R6Cm3AiqInMw==}
peerDependencies:
'@types/react': ^17.0.0 || ^18 || ^19
react: ^17.0.2 || ^18 || ^19
react-dom: ^17.0.2 || ^18 || ^19
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -3809,6 +3939,9 @@ packages:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
sax@1.2.1:
resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -3931,6 +4064,9 @@ packages:
resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==}
engines: {node: '>=12'}
spotify-audio-element@1.0.2:
resolution: {integrity: sha512-YEovyyeJTJMzdSVqFw/Fx19e1gdcD4bmZZ/fWS0Ji58KTpvAT2rophgK87ocqpy6eJNSmIHikhgbRjGWumgZew==}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -4039,6 +4175,9 @@ packages:
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
super-media-element@1.4.2:
resolution: {integrity: sha512-9pP/CVNp4NF2MNlRzLwQkjiTgKKe9WYXrLh9+8QokWmMxz+zt2mf1utkWLco26IuA3AfVcTb//qtlTIjY3VHxA==}
supports-color@10.0.0:
resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==}
engines: {node: '>=18'}
@@ -4063,6 +4202,9 @@ packages:
resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
engines: {node: '>=10'}
tiktok-video-element@0.1.0:
resolution: {integrity: sha512-PVWUlpDdQ/LPXi7x4/furfD7Xh1L72CgkGCaMsZBIjvxucMGm1DDPJdM9IhWBFfo6tuR4cYVO/v596r6GG/lvQ==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4148,6 +4290,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
twitch-video-element@0.1.2:
resolution: {integrity: sha512-/up4KiWiTYiav+CUo+/DbV8JhP4COwEKSo8h1H/Zft/5NzZ/ZiIQ48h7erFKvwzalN0GfkEGGIfwIzAO0h7FHQ==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4182,6 +4327,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ua-parser-js@1.0.40:
resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==}
hasBin: true
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
@@ -4286,6 +4435,9 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
vimeo-video-element@1.5.3:
resolution: {integrity: sha512-OQWyGS9nTouMqfRJyvmAm/n6IRhZ7x3EfPAef+Q+inGBeHa3SylDbtyeB/rEBd4B/T/lcYBW3rjaD9W2DRYkiQ==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -4401,6 +4553,10 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
weakmap-polyfill@2.0.4:
resolution: {integrity: sha512-ZzxBf288iALJseijWelmECm/1x7ZwQn3sMYIkDr2VvZp7r6SEKuT8D0O9Wiq6L9Nl5mazrOMcmiZE/2NCenaxw==}
engines: {node: '>=8.10.0'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -4436,6 +4592,9 @@ packages:
engines: {node: '>=8'}
hasBin: true
wistia-video-element@1.3.3:
resolution: {integrity: sha512-ZVC8HH8uV3mQGcSz10MACLDalao/0YdVverNN4GNFsOXiumfqSiZnRVc8WZEywgVckBkR7+yerQYESYPDzvTfQ==}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -4509,6 +4668,9 @@ packages:
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
engines: {node: '>=12.20'}
youtube-video-element@1.6.1:
resolution: {integrity: sha512-FDRgXlPxpe1bh6HlhL6GfJVcvVNaZKCcLEZ90X1G3Iu+z2g2cIhm2OWj9abPZq1Zqit6SY7Gwh13H9g7acoBnQ==}
zod-validation-error@3.5.3:
resolution: {integrity: sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==}
engines: {node: '>=18.0.0'}
@@ -5235,6 +5397,43 @@ snapshots:
'@microsoft/tsdoc@0.15.1': {}
'@mux/mux-data-google-ima@0.2.8':
dependencies:
mux-embed: 5.9.0
'@mux/mux-player-react@3.5.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@mux/mux-player': 3.5.3(react@18.3.1)
'@mux/playback-core': 0.30.1
prop-types: 15.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@mux/mux-player@3.5.3(react@18.3.1)':
dependencies:
'@mux/mux-video': 0.26.1
'@mux/playback-core': 0.30.1
media-chrome: 4.11.1(react@18.3.1)
player.style: 0.1.9(react@18.3.1)
transitivePeerDependencies:
- react
'@mux/mux-video@0.26.1':
dependencies:
'@mux/mux-data-google-ima': 0.2.8
'@mux/playback-core': 0.30.1
castable-video: 1.1.10
custom-media-element: 1.4.5
media-tracks: 0.3.3
'@mux/playback-core@0.30.1':
dependencies:
hls.js: 1.6.9
mux-embed: 5.11.0
'@nanostores/react@0.7.3(nanostores@0.11.4)(react@18.3.1)':
dependencies:
nanostores: 0.11.4
@@ -5690,6 +5889,8 @@ snapshots:
optionalDependencies:
typescript: 5.8.3
'@svta/common-media-library@0.12.4': {}
'@swc/core-darwin-arm64@1.12.9':
optional: true
@@ -5971,6 +6172,13 @@ snapshots:
'@typescript-eslint/types': 8.37.0
eslint-visitor-keys: 4.2.1
'@vercel/edge@1.2.2': {}
'@vimeo/player@2.29.0':
dependencies:
native-promise-only: 0.8.1
weakmap-polyfill: 2.0.4
'@vitejs/plugin-react-swc@3.10.2(vite@7.0.5(@types/node@22.16.0)(jiti@2.4.2))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.11
@@ -6304,6 +6512,19 @@ snapshots:
base64-js@1.5.1: {}
bcp-47-match@2.0.3: {}
bcp-47-normalize@2.3.0:
dependencies:
bcp-47: 2.1.0
bcp-47-match: 2.0.3
bcp-47@2.1.0:
dependencies:
is-alphabetical: 2.0.1
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
better-opn@3.0.2:
dependencies:
open: 8.4.2
@@ -6366,6 +6587,14 @@ snapshots:
caniuse-lite@1.0.30001727: {}
castable-video@1.1.10:
dependencies:
custom-media-element: 1.4.5
ce-la-react@0.3.1(react@18.3.1):
dependencies:
react: 18.3.1
chai@5.2.0:
dependencies:
assertion-error: 2.0.1
@@ -6423,6 +6652,8 @@ snapshots:
clone@1.0.4: {}
cloudflare-video-element@1.3.3: {}
cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
@@ -6435,6 +6666,8 @@ snapshots:
- '@types/react'
- '@types/react-dom'
codem-isoboxer@0.3.10: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -6510,6 +6743,8 @@ snapshots:
csstype@3.1.3: {}
custom-media-element@1.4.5: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
@@ -6546,6 +6781,24 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
dash-video-element@0.1.6:
dependencies:
custom-media-element: 1.4.5
dashjs: 5.0.3
dashjs@5.0.3:
dependencies:
'@svta/common-media-library': 0.12.4
bcp-47-match: 2.0.3
bcp-47-normalize: 2.3.0
codem-isoboxer: 0.3.10
fast-deep-equal: 3.1.3
html-entities: 2.6.0
imsc: 1.1.5
localforage: 1.10.0
path-browserify: 1.0.1
ua-parser-js: 1.0.40
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -7244,10 +7497,20 @@ snapshots:
he@1.2.0: {}
hls-video-element@1.5.6:
dependencies:
custom-media-element: 1.4.5
hls.js: 1.6.9
media-tracks: 0.3.3
hls.js@1.6.9: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
html-entities@2.6.0: {}
html-escaper@2.0.2: {}
html-parse-stringify@3.0.1:
@@ -7283,6 +7546,8 @@ snapshots:
ignore@7.0.5: {}
immediate@3.0.6: {}
immer@10.1.1: {}
import-fresh@3.3.1:
@@ -7292,6 +7557,10 @@ snapshots:
import-lazy@4.0.0: {}
imsc@1.1.5:
dependencies:
sax: 1.2.1
imurmurhash@0.1.4: {}
indent-string@4.0.0: {}
@@ -7310,6 +7579,13 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -7352,6 +7628,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-decimal@2.0.1: {}
is-docker@2.2.1: {}
is-extglob@2.1.1: {}
@@ -7553,6 +7831,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lie@3.1.1:
dependencies:
immediate: 3.0.6
lines-and-columns@1.2.4: {}
linkify-react@4.3.1(linkifyjs@4.3.1)(react@18.3.1):
@@ -7575,6 +7857,10 @@ snapshots:
pkg-types: 2.2.0
quansync: 0.2.10
localforage@1.10.0:
dependencies:
lie: 3.1.1
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -7634,6 +7920,21 @@ snapshots:
mdn-data@2.0.14: {}
media-chrome@4.11.1(react@18.3.1):
dependencies:
'@vercel/edge': 1.2.2
ce-la-react: 0.3.1(react@18.3.1)
transitivePeerDependencies:
- react
media-chrome@4.13.0(react@18.3.1):
dependencies:
ce-la-react: 0.3.1(react@18.3.1)
transitivePeerDependencies:
- react
media-tracks@0.3.3: {}
memoize-one@6.0.0: {}
merge2@1.4.1: {}
@@ -7690,6 +7991,10 @@ snapshots:
muggle-string@0.4.1: {}
mux-embed@5.11.0: {}
mux-embed@5.9.0: {}
nano-css@5.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
@@ -7711,6 +8016,8 @@ snapshots:
nanostores@1.0.1: {}
native-promise-only@0.8.1: {}
natural-compare@1.4.0: {}
nearley@2.20.1:
@@ -7929,6 +8236,12 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
player.style@0.1.9(react@18.3.1):
dependencies:
media-chrome: 4.11.1(react@18.3.1)
transitivePeerDependencies:
- react
pluralize@8.0.0: {}
possible-typed-array-names@1.1.0: {}
@@ -8066,6 +8379,24 @@ snapshots:
react-is@17.0.2: {}
react-player@3.3.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@mux/mux-player-react': 3.5.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/react': 18.3.23
cloudflare-video-element: 1.3.3
dash-video-element: 0.1.6
hls-video-element: 1.5.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
spotify-audio-element: 1.0.2
tiktok-video-element: 0.1.0
twitch-video-element: 0.1.2
vimeo-video-element: 1.5.3
wistia-video-element: 1.3.3
youtube-video-element: 1.6.1
transitivePeerDependencies:
- '@types/react-dom'
react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -8360,6 +8691,8 @@ snapshots:
safe-stable-stringify@2.5.0: {}
sax@1.2.1: {}
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
@@ -8484,6 +8817,8 @@ snapshots:
split-on-first@3.0.0: {}
spotify-audio-element@1.0.2: {}
sprintf-js@1.0.3: {}
stable-hash@0.0.6: {}
@@ -8627,6 +8962,8 @@ snapshots:
stylis@4.3.6: {}
super-media-element@1.4.2: {}
supports-color@10.0.0: {}
supports-color@7.2.0:
@@ -8647,6 +8984,8 @@ snapshots:
throttle-debounce@3.0.1: {}
tiktok-video-element@0.1.0: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -8709,6 +9048,8 @@ snapshots:
tslib@2.8.1: {}
twitch-video-element@0.1.2: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -8752,6 +9093,8 @@ snapshots:
typescript@5.8.3: {}
ua-parser-js@1.0.40: {}
ufo@1.6.1: {}
unbox-primitive@1.1.0:
@@ -8834,6 +9177,10 @@ snapshots:
uuid@11.1.0: {}
vimeo-video-element@1.5.3:
dependencies:
'@vimeo/player': 2.29.0
vite-node@3.2.4(@types/node@22.16.0)(jiti@2.4.2):
dependencies:
cac: 6.7.14
@@ -8962,6 +9309,8 @@ snapshots:
dependencies:
defaults: 1.0.4
weakmap-polyfill@2.0.4: {}
webidl-conversions@3.0.1: {}
webpack-virtual-modules@0.6.2: {}
@@ -9021,6 +9370,10 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wistia-video-element@1.3.3:
dependencies:
super-media-element: 1.4.2
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
@@ -9067,6 +9420,8 @@ snapshots:
yocto-queue@1.2.1: {}
youtube-video-element@1.6.1: {}
zod-validation-error@3.5.3(zod@3.25.76):
dependencies:
zod: 3.25.76

View File

@@ -43,6 +43,8 @@
"move": "Move",
"movingImagesToBoard_one": "Moving {{count}} image to board:",
"movingImagesToBoard_other": "Moving {{count}} images to board:",
"movingVideosToBoard_one": "Moving {{count}} video to board:",
"movingVideosToBoard_other": "Moving {{count}} videos to board:",
"myBoard": "My Board",
"noBoards": "No {{boardType}} Boards",
"noMatching": "No matching Boards",
@@ -59,6 +61,8 @@
"imagesWithCount_other": "{{count}} images",
"assetsWithCount_one": "{{count}} asset",
"assetsWithCount_other": "{{count}} assets",
"videosWithCount_one": "{{count}} video",
"videosWithCount_other": "{{count}} videos",
"updateBoardError": "Error updating board"
},
"accordions": {
@@ -361,6 +365,9 @@
"deleteImage_one": "Delete Image",
"deleteImage_other": "Delete {{count}} Images",
"deleteImagePermanent": "Deleted images cannot be restored.",
"deleteVideo_one": "Delete Video",
"deleteVideo_other": "Delete {{count}} Videos",
"deleteVideoPermanent": "Deleted videos cannot be restored.",
"displayBoardSearch": "Board Search",
"displaySearch": "Image Search",
"download": "Download",
@@ -380,9 +387,10 @@
"sortDirection": "Sort Direction",
"showStarredImagesFirst": "Show Starred Images First",
"noImageSelected": "No Image Selected",
"noVideoSelected": "No Video Selected",
"noImagesInGallery": "No Images to Display",
"starImage": "Star Image",
"unstarImage": "Unstar Image",
"starImage": "Star",
"unstarImage": "Unstar",
"unableToLoad": "Unable to load Gallery",
"deleteSelection": "Delete Selection",
"downloadSelection": "Download Selection",
@@ -411,7 +419,9 @@
"openViewer": "Open Viewer",
"closeViewer": "Close Viewer",
"move": "Move",
"useForPromptGeneration": "Use for Prompt Generation"
"useForPromptGeneration": "Use for Prompt Generation",
"videos": "Videos",
"videosTab": "Videos you've created and saved within Invoke."
},
"hotkeys": {
"hotkeys": "Hotkeys",
@@ -456,6 +466,10 @@
"title": "Select the Queue Tab",
"desc": "Selects the Queue tab."
},
"selectVideoTab": {
"title": "Select the Video Tab",
"desc": "Selects the Video tab."
},
"focusPrompt": {
"title": "Focus Prompt",
"desc": "Move cursor focus to the positive prompt."
@@ -482,6 +496,9 @@
"key": "1"
}
},
"video": {
"title": "Video"
},
"canvas": {
"title": "Canvas",
"selectBrushTool": {
@@ -781,11 +798,13 @@
"guidance": "Guidance",
"height": "Height",
"imageDetails": "Image Details",
"videoDetails": "Video Details",
"imageDimensions": "Image Dimensions",
"metadata": "Metadata",
"model": "Model",
"negativePrompt": "Negative Prompt",
"noImageDetails": "No image details found",
"noVideoDetails": "No video details found",
"noMetaData": "No metadata found",
"noRecallParameters": "No parameters to recall found",
"parameterSet": "Parameter {{parameter}} set",
@@ -803,7 +822,11 @@
"vae": "VAE",
"width": "Width",
"workflow": "Workflow",
"canvasV2Metadata": "Canvas Layers"
"canvasV2Metadata": "Canvas Layers",
"videoModel": "Model",
"videoDuration": "Duration",
"videoAspectRatio": "Aspect Ratio",
"videoResolution": "Resolution"
},
"modelManager": {
"active": "active",
@@ -1189,6 +1212,7 @@
},
"parameters": {
"aspect": "Aspect",
"duration": "Duration",
"lockAspectRatio": "Lock Aspect Ratio",
"swapDimensions": "Swap Dimensions",
"setToOptimalSize": "Optimize size for model",
@@ -1213,9 +1237,14 @@
"height": "Height",
"imageFit": "Fit Initial Image To Output Size",
"images": "Images",
"images_withCount_one": "Image",
"images_withCount_other": "Images",
"videos_withCount_one": "Video",
"videos_withCount_other": "Videos",
"infillMethod": "Infill Method",
"infillColorValue": "Fill Color",
"info": "Info",
"startingFrameImage": "Start Frame",
"invoke": {
"addingImagesTo": "Adding images to",
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your account settings to upgrade.",
@@ -1239,6 +1268,7 @@
"batchNodeCollectionSizeMismatchNoGroupId": "Batch group collection size mismatch",
"batchNodeCollectionSizeMismatch": "Collection size mismatch on Batch {{batchGroupId}}",
"noModelSelected": "No model selected",
"noStartingFrameImage": "No starting frame image",
"noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation",
"noFLUXVAEModelSelected": "No VAE model selected for FLUX generation",
"noCLIPEmbedModelSelected": "No CLIP Embed model selected for FLUX generation",
@@ -1251,7 +1281,6 @@
"modelIncompatibleScaledBboxWidth": "Scaled bbox width is {{width}} but {{model}} requires multiple of {{multiple}}",
"modelIncompatibleScaledBboxHeight": "Scaled bbox height is {{height}} but {{model}} requires multiple of {{multiple}}",
"fluxModelMultipleControlLoRAs": "Can only use 1 Control LoRA at a time",
"fluxKontextMultipleReferenceImages": "Can only use 1 Reference Image at a time with FLUX Kontext via BFL API",
"canvasIsFiltering": "Canvas is busy (filtering)",
"canvasIsTransforming": "Canvas is busy (transforming)",
"canvasIsRasterizing": "Canvas is busy (rasterizing)",
@@ -1261,7 +1290,8 @@
"noNodesInGraph": "No nodes in graph",
"systemDisconnected": "System disconnected",
"promptExpansionPending": "Prompt expansion in progress",
"promptExpansionResultPending": "Please accept or discard your prompt expansion result"
"promptExpansionResultPending": "Please accept or discard your prompt expansion result",
"videoIsDisabled": "Video generation is not enabled for {{accountType}} accounts."
},
"maskBlur": "Mask Blur",
"negativePromptPlaceholder": "Negative Prompt",
@@ -1279,9 +1309,11 @@
"seamlessXAxis": "Seamless X Axis",
"seamlessYAxis": "Seamless Y Axis",
"seed": "Seed",
"videoActions": "Video Actions",
"imageActions": "Image Actions",
"sendToCanvas": "Send To Canvas",
"sendToUpscale": "Send To Upscale",
"sendToVideo": "Send To Video",
"showOptionsPanel": "Show Side Panel (O or T)",
"shuffle": "Shuffle Seed",
"steps": "Steps",
@@ -1293,6 +1325,7 @@
"postProcessing": "Post-Processing (Shift + U)",
"processImage": "Process Image",
"upscaling": "Upscaling",
"video": "Video",
"useAll": "Use All",
"useSize": "Use Size",
"useCpuNoise": "Use CPU Noise",
@@ -1304,6 +1337,7 @@
"gaussianBlur": "Gaussian Blur",
"boxBlur": "Box Blur",
"staged": "Staged",
"resolution": "Resolution",
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your <LinkComponent>account settings</LinkComponent> to upgrade."
},
"dynamicPrompts": {
@@ -2565,19 +2599,30 @@
"queue": "Queue",
"upscaling": "Upscaling",
"upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)",
"video": "Video",
"gallery": "Gallery"
},
"panels": {
"launchpad": "Launchpad",
"workflowEditor": "Workflow Editor",
"imageViewer": "Image Viewer",
"canvas": "Canvas"
"imageViewer": "Viewer",
"canvas": "Canvas",
"video": "Video"
},
"launchpad": {
"workflowsTitle": "Go deep with Workflows.",
"upscalingTitle": "Upscale and add detail.",
"canvasTitle": "Edit and refine on Canvas.",
"generateTitle": "Generate images from text prompts.",
"videoTitle": "Generate videos from text prompts.",
"video": {
"startingFrameCalloutTitle": "Add a Starting Frame",
"startingFrameCalloutDesc": "Add an image to control the first frame of your video."
},
"addStartingFrame": {
"title": "Add a Starting Frame",
"description": "Add an image to control the first frame of your video."
},
"modelGuideText": "Want to learn what prompts work best for each model?",
"modelGuideLink": "Check out our Model Guide.",
"createNewWorkflowFromScratch": "Create a new Workflow from scratch",
@@ -2652,6 +2697,10 @@
}
}
},
"video": {
"noVideoSelected": "No video selected",
"selectFromGallery": "Select a video from the gallery to play"
},
"system": {
"enableLogging": "Enable Logging",
"logLevel": {

View File

@@ -2,12 +2,12 @@ import { useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow';
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useRecallAll } from 'features/gallery/hooks/useRecallAllImageMetadata';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
@@ -15,8 +15,8 @@ import type { ImageDTO } from 'services/api/types';
export const GlobalImageHotkeys = memo(() => {
useAssertSingleton('GlobalImageHotkeys');
const imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
const imageDTO = useImageDTO(lastSelectedItem?.type === 'image' ? lastSelectedItem.id : null);
if (!imageDTO) {
return null;

View File

@@ -3,10 +3,12 @@ import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardMo
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
import { DeleteVideoModal } from 'features/deleteVideoModal/components/DeleteVideoModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { ImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu';
import { VideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu';
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal';
import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal';
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
@@ -31,6 +33,7 @@ export const GlobalModalIsolator = memo(() => {
return (
<>
<DeleteImageModal />
<DeleteVideoModal />
<ChangeBoardModal />
<DynamicPromptsModal />
<StylePresetModal />
@@ -47,6 +50,7 @@ export const GlobalModalIsolator = memo(() => {
<DeleteBoardModal />
<GlobalImageHotkeys />
<ImageContextMenu />
<VideoContextMenu />
<FullscreenDropzone />
<VideosModal />
<SaveWorkflowAsDialog />

View File

@@ -1,16 +1,14 @@
import 'i18n';
import type { Middleware } from '@reduxjs/toolkit';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import type { InvokeAIUIProps } from 'app/components/types';
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
import type { LoggingOverrides } from 'app/logging/logger';
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
import { addStorageListeners } from 'app/store/enhancers/reduxRemember/driver';
import { $accountSettingsLink } from 'app/store/nanostores/accountSettingsLink';
import { $accountTypeText } from 'app/store/nanostores/accountTypeText';
import { $authToken } from 'app/store/nanostores/authToken';
import { $baseUrl } from 'app/store/nanostores/baseUrl';
import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
import type { CustomStarUi } from 'app/store/nanostores/customStarUI';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { $isDebugging } from 'app/store/nanostores/isDebugging';
import { $logo } from 'app/store/nanostores/logo';
@@ -20,11 +18,10 @@ import { $projectId, $projectName, $projectUrl } from 'app/store/nanostores/proj
import { $queueId, DEFAULT_QUEUE_ID } from 'app/store/nanostores/queueId';
import { $store } from 'app/store/nanostores/store';
import { $toastMap } from 'app/store/nanostores/toastMap';
import { $videoUpsellComponent } from 'app/store/nanostores/videoUpsellComponent';
import { $whatsNew } from 'app/store/nanostores/whatsNew';
import { createStore } from 'app/store/store';
import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading';
import type { WorkflowSortOption, WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import {
$workflowLibraryCategoriesOptions,
$workflowLibrarySortOptions,
@@ -33,47 +30,13 @@ import {
DEFAULT_WORKFLOW_LIBRARY_SORT_OPTIONS,
DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES,
} from 'features/nodes/store/workflowLibrarySlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { ToastConfig } from 'features/toast/toast';
import type { PropsWithChildren, ReactNode } from 'react';
import React, { lazy, memo, useEffect, useLayoutEffect, useState } from 'react';
import { Provider } from 'react-redux';
import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares';
import { $socketOptions } from 'services/events/stores';
import type { ManagerOptions, SocketOptions } from 'socket.io-client';
const App = lazy(() => import('./App'));
interface Props extends PropsWithChildren {
apiUrl?: string;
openAPISchemaUrl?: string;
token?: string;
config?: PartialAppConfig;
customNavComponent?: ReactNode;
accountSettingsLink?: string;
middleware?: Middleware[];
projectId?: string;
projectName?: string;
projectUrl?: string;
queueId?: string;
studioInitAction?: StudioInitAction;
customStarUi?: CustomStarUi;
socketOptions?: Partial<ManagerOptions & SocketOptions>;
isDebugging?: boolean;
logo?: ReactNode;
toastMap?: Record<string, ToastConfig>;
whatsNew?: ReactNode[];
workflowCategories?: WorkflowCategory[];
workflowTagCategories?: WorkflowTagCategory[];
workflowSortOptions?: WorkflowSortOption[];
loggingOverrides?: LoggingOverrides;
/**
* If provided, overrides in-app navigation to the model manager
*/
onClickGoToModelManager?: () => void;
storagePersistDebounce?: number;
}
const InvokeAIUI = ({
apiUrl,
openAPISchemaUrl,
@@ -92,6 +55,8 @@ const InvokeAIUI = ({
isDebugging = false,
logo,
toastMap,
accountTypeText,
videoUpsellComponent,
workflowCategories,
workflowTagCategories,
workflowSortOptions,
@@ -99,7 +64,7 @@ const InvokeAIUI = ({
onClickGoToModelManager,
whatsNew,
storagePersistDebounce = 300,
}: Props) => {
}: InvokeAIUIProps) => {
const [store, setStore] = useState<ReturnType<typeof createStore> | undefined>(undefined);
const [didRehydrate, setDidRehydrate] = useState(false);
@@ -180,6 +145,26 @@ const InvokeAIUI = ({
};
}, [customStarUi]);
useEffect(() => {
if (accountTypeText) {
$accountTypeText.set(accountTypeText);
}
return () => {
$accountTypeText.set('');
};
}, [accountTypeText]);
useEffect(() => {
if (videoUpsellComponent) {
$videoUpsellComponent.set(videoUpsellComponent);
}
return () => {
$videoUpsellComponent.set(undefined);
};
}, [videoUpsellComponent]);
useEffect(() => {
if (customNavComponent) {
$customNavComponent.set(customNavComponent);

View File

@@ -0,0 +1,43 @@
import type { Middleware } from '@reduxjs/toolkit';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import type { LoggingOverrides } from 'app/logging/logger';
import type { CustomStarUi } from 'app/store/nanostores/customStarUI';
import type { PartialAppConfig } from 'app/types/invokeai';
import type { SocketOptions } from 'dgram';
import type { WorkflowSortOption, WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { ToastConfig } from 'features/toast/toast';
import type { PropsWithChildren, ReactNode } from 'react';
import type { ManagerOptions } from 'socket.io-client';
export interface InvokeAIUIProps extends PropsWithChildren {
apiUrl?: string;
openAPISchemaUrl?: string;
token?: string;
config?: PartialAppConfig;
customNavComponent?: ReactNode;
accountSettingsLink?: string;
middleware?: Middleware[];
projectId?: string;
projectName?: string;
projectUrl?: string;
queueId?: string;
studioInitAction?: StudioInitAction;
customStarUi?: CustomStarUi;
socketOptions?: Partial<ManagerOptions & SocketOptions>;
isDebugging?: boolean;
logo?: ReactNode;
toastMap?: Record<string, ToastConfig>;
accountTypeText?: string;
videoUpsellComponent?: ReactNode;
whatsNew?: ReactNode[];
workflowCategories?: WorkflowCategory[];
workflowTagCategories?: WorkflowTagCategory[];
workflowSortOptions?: WorkflowSortOption[];
loggingOverrides?: LoggingOverrides;
/**
* If provided, overrides in-app navigation to the model manager
*/
onClickGoToModelManager?: () => void;
storagePersistDebounce?: number;
}

View File

@@ -42,6 +42,7 @@ type StudioDestinationAction = _StudioInitAction<
| 'canvas'
| 'workflows'
| 'upscaling'
| 'video'
| 'viewAllWorkflows'
| 'viewAllWorkflowsRecommended'
| 'viewAllStylePresets';
@@ -118,7 +119,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
const metadata = getImageMetadataResult.value;
store.dispatch(canvasReset());
// This shows a toast
await MetadataUtils.recallAll(metadata, store);
await MetadataUtils.recallAllImageMetadata(metadata, store);
},
[store, t]
);
@@ -177,6 +178,10 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
// Go to the upscaling tab
navigationApi.switchToTab('upscaling');
break;
case 'video':
// Go to the video tab
await navigationApi.focusPanel('video', LAUNCHPAD_PANEL_ID);
break;
case 'viewAllWorkflows':
// Go to the workflows tab and open the workflow library modal
navigationApi.switchToTab('workflows');

View File

@@ -26,6 +26,7 @@ export const zLogNamespace = z.enum([
'system',
'queue',
'workflows',
'video',
]);
export type LogNamespace = z.infer<typeof zLogNamespace>;

View File

@@ -1,7 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/store';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { itemSelected } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
export const appStarted = createAction('app/appStarted');
@@ -18,11 +18,13 @@ export const addAppStartedListener = (startAppListening: AppStartListening) => {
const firstImageLoad = await take(imagesApi.endpoints.getImageNames.matchFulfilled);
if (firstImageLoad !== null) {
const [{ payload }] = firstImageLoad;
const selectedImage = selectLastSelectedImage(getState());
const selectedImage = selectLastSelectedItem(getState());
if (selectedImage) {
return;
}
dispatch(imageSelected(payload.image_names.at(0) ?? null));
if (payload.image_names[0]) {
dispatch(itemSelected({ type: 'image', id: payload.image_names[0] }));
}
}
},
});

View File

@@ -1,8 +1,14 @@
import { isAnyOf } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/store';
import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
import {
selectGalleryView,
selectGetImageNamesQueryArgs,
selectGetVideoIdsQueryArgs,
selectSelectedBoardId,
} from 'features/gallery/store/gallerySelectors';
import { boardIdSelected, galleryViewChanged, itemSelected } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import { videosApi } from 'services/api/endpoints/videos';
export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => {
startAppListening({
@@ -11,35 +17,61 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
// Cancel any in-progress instances of this listener, we don't want to select an image from a previous board
cancelActiveListeners();
if (boardIdSelected.match(action) && action.payload.selectedImageName) {
// This action already has a selected image name, we trust it is valid
if (boardIdSelected.match(action) && action.payload.select) {
// This action already has a resource selection - skip the below auto-selection logic
return;
}
const state = getState();
const board_id = selectSelectedBoardId(state);
const view = selectGalleryView(state);
const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id };
if (view === 'images' || view === 'assets') {
const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id };
// wait until the board has some images - maybe it already has some from a previous fetch
// must use getState() to ensure we do not have stale state
const isSuccess = await condition(
() => imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).isSuccess,
5000
);
// wait until the board has some images - maybe it already has some from a previous fetch
// must use getState() to ensure we do not have stale state
const isSuccess = await condition(
() => imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).isSuccess,
5000
);
if (!isSuccess) {
dispatch(itemSelected(null));
return;
}
if (!isSuccess) {
dispatch(imageSelected(null));
return;
// the board was just changed - we can select the first image
const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).data?.image_names;
const imageToSelect = imageNames && imageNames.length > 0 ? imageNames[0] : null;
if (imageToSelect) {
dispatch(itemSelected({ type: 'image', id: imageToSelect }));
}
} else {
const queryArgs = { ...selectGetVideoIdsQueryArgs(state), board_id };
// wait until the board has some images - maybe it already has some from a previous fetch
// must use getState() to ensure we do not have stale state
const isSuccess = await condition(
() => videosApi.endpoints.getVideoIds.select(queryArgs)(getState()).isSuccess,
5000
);
if (!isSuccess) {
dispatch(itemSelected(null));
return;
}
// the board was just changed - we can select the first image
const videoIds = videosApi.endpoints.getVideoIds.select(queryArgs)(getState()).data?.video_ids;
const videoToSelect = videoIds && videoIds.length > 0 ? videoIds[0] : null;
if (videoToSelect) {
dispatch(itemSelected({ type: 'video', id: videoToSelect }));
}
}
// the board was just changed - we can select the first image
const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).data?.image_names;
const imageToSelect = imageNames?.at(0) ?? null;
dispatch(imageSelected(imageToSelect));
},
});
};

View File

@@ -13,12 +13,14 @@ import {
import { refImageModelChanged, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier, isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types';
import { zModelIdentifierField } from 'features/nodes/types/common';
import { modelSelected } from 'features/parameters/store/actions';
import {
postProcessingModelChanged,
tileControlnetModelChanged,
upscaleModelChanged,
} from 'features/parameters/store/upscaleSlice';
import { videoModelChanged } from 'features/parameters/store/videoSlice';
import {
zParameterCLIPEmbedModel,
zParameterSpandrelImageToImageModel,
@@ -41,6 +43,7 @@ import {
isRefinerMainModelModelConfig,
isSpandrelImageToImageModelConfig,
isT5EncoderModelConfig,
isVideoModelConfig,
} from 'services/api/types';
import type { JsonObject } from 'type-fest';
@@ -81,6 +84,7 @@ export const addModelsLoadedListener = (startAppListening: AppStartListening) =>
handleCLIPEmbedModels(models, state, dispatch, log);
handleFLUXVAEModels(models, state, dispatch, log);
handleFLUXReduxModels(models, state, dispatch, log);
handleVideoModels(models, state, dispatch, log);
},
});
};
@@ -193,6 +197,22 @@ const handleLoRAModels: ModelHandler = (models, state, dispatch, log) => {
});
};
const handleVideoModels: ModelHandler = (models, state, dispatch, log) => {
const videoModels = models.filter(isVideoModelConfig);
const selectedVideoModel = state.video.videoModel;
if (selectedVideoModel && videoModels.some((m) => m.key === selectedVideoModel.key)) {
return;
}
const firstModel = videoModels[0] || null;
if (firstModel) {
log.debug({ firstModel }, 'No video model selected, selecting first available video model');
dispatch(videoModelChanged({ videoModel: zModelIdentifierField.parse(firstModel) }));
return;
}
};
const handleControlAdapterModels: ModelHandler = (models, state, dispatch, log) => {
const caModels = models.filter(isControlLayerModelConfig);
selectCanvasSlice(state).controlLayers.entities.forEach((entity) => {

View File

@@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const $accountTypeText = atom<string>('');

View File

@@ -0,0 +1,4 @@
import { atom } from 'nanostores';
import type { ReactNode } from 'react';
export const $videoUpsellComponent = atom<ReactNode | undefined>(undefined);

View File

@@ -33,6 +33,7 @@ import { nodesSliceConfig } from 'features/nodes/store/nodesSlice';
import { workflowLibrarySliceConfig } from 'features/nodes/store/workflowLibrarySlice';
import { workflowSettingsSliceConfig } from 'features/nodes/store/workflowSettingsSlice';
import { upscaleSliceConfig } from 'features/parameters/store/upscaleSlice';
import { videoSliceConfig } from 'features/parameters/store/videoSlice';
import { queueSliceConfig } from 'features/queue/store/queueSlice';
import { stylePresetSliceConfig } from 'features/stylePresets/store/stylePresetSlice';
import { configSliceConfig } from 'features/system/store/configSlice';
@@ -78,6 +79,7 @@ const SLICE_CONFIGS = {
[systemSliceConfig.slice.reducerPath]: systemSliceConfig,
[uiSliceConfig.slice.reducerPath]: uiSliceConfig,
[upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig,
[videoSliceConfig.slice.reducerPath]: videoSliceConfig,
[workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig,
[workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig,
};
@@ -111,6 +113,7 @@ const ALL_REDUCERS = {
[systemSliceConfig.slice.reducerPath]: systemSliceConfig.slice.reducer,
[uiSliceConfig.slice.reducerPath]: uiSliceConfig.slice.reducer,
[upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig.slice.reducer,
[videoSliceConfig.slice.reducerPath]: videoSliceConfig.slice.reducer,
[workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig.slice.reducer,
[workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig.slice.reducer,
};

View File

@@ -80,6 +80,7 @@ export const zAppConfig = z.object({
allowClientSideUpload: z.boolean(),
allowPublishWorkflows: z.boolean(),
allowPromptExpansion: z.boolean(),
allowVideo: z.boolean(),
disabledTabs: z.array(zTabName),
disabledFeatures: z.array(zAppFeature),
disabledSDFeatures: z.array(zSDFeature),
@@ -140,8 +141,9 @@ export const getDefaultAppConfig = (): AppConfig => ({
allowClientSideUpload: false,
allowPublishWorkflows: false,
allowPromptExpansion: false,
allowVideo: false, // used to determine if video is enabled vs upsell
shouldShowCredits: false,
disabledTabs: [],
disabledTabs: ['video'], // used to determine if video functionality is visible
disabledFeatures: ['lightbox', 'faceRestore', 'batches'] satisfies AppFeature[],
disabledSDFeatures: ['variation', 'symmetry', 'hires', 'perlinNoise', 'noiseThreshold'] satisfies SDFeature[],
sd: {

View File

@@ -37,6 +37,7 @@ const REGION_NAMES = [
'workflows',
'progress',
'settings',
'video',
] as const;
/**
* The names of the focus regions.

View File

@@ -6,13 +6,13 @@ import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useDownloadImage = () => {
export const useDownloadItem = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const authToken = useStore($authToken);
const downloadImage = useCallback(
async (image_url: string, image_name: string) => {
const downloadItem = useCallback(
async (item_url: string, item_id: string) => {
try {
const requestOpts = authToken
? {
@@ -21,7 +21,7 @@ export const useDownloadImage = () => {
},
}
: {};
const blob = await fetch(image_url, requestOpts).then((resp) => resp.blob());
const blob = await fetch(item_url, requestOpts).then((resp) => resp.blob());
if (!blob) {
throw new Error('Unable to create Blob');
}
@@ -30,7 +30,7 @@ export const useDownloadImage = () => {
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = image_name;
a.download = item_id;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@@ -47,5 +47,5 @@ export const useDownloadImage = () => {
[t, dispatch, authToken]
);
return { downloadImage };
return { downloadItem };
};

View File

@@ -1,5 +1,6 @@
import { useAppStore } from 'app/store/storeHooks';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useDeleteVideoModalApi } from 'features/deleteVideoModal/store/state';
import { selectSelection } from 'features/gallery/store/gallerySelectors';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem';
@@ -12,6 +13,7 @@ import { getFocusedRegion } from './focus';
export const useGlobalHotkeys = () => {
const { dispatch, getState } = useAppStore();
const isVideoEnabled = useFeatureStatus('video');
const isModelManagerEnabled = useFeatureStatus('modelManager');
const queue = useInvoke();
@@ -92,6 +94,18 @@ export const useGlobalHotkeys = () => {
dependencies: [dispatch],
});
useRegisteredHotkeys({
id: 'selectVideoTab',
category: 'app',
callback: () => {
navigationApi.switchToTab('video');
},
options: {
enabled: isVideoEnabled,
},
dependencies: [dispatch],
});
useRegisteredHotkeys({
id: 'selectWorkflowsTab',
category: 'app',
@@ -123,6 +137,8 @@ export const useGlobalHotkeys = () => {
});
const deleteImageModalApi = useDeleteImageModalApi();
const deleteVideoModalApi = useDeleteVideoModalApi();
useRegisteredHotkeys({
id: 'deleteSelection',
category: 'gallery',
@@ -135,7 +151,13 @@ export const useGlobalHotkeys = () => {
if (!selection.length) {
return;
}
deleteImageModalApi.delete(selection);
if (selection.every(({ type }) => type === 'image')) {
deleteImageModalApi.delete(selection.map((s) => s.id));
} else if (selection.every(({ type }) => type === 'video')) {
deleteVideoModalApi.delete(selection.map((s) => s.id));
} else {
// no-op, we expect selections to always be only images or only video
}
},
dependencies: [getState, deleteImageModalApi],
});

View File

@@ -13,12 +13,18 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
import { useAddVideosToBoardMutation, useRemoveVideosFromBoardMutation } from 'services/api/endpoints/videos';
const selectImagesToChange = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.image_names
);
const selectVideosToChange = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.video_ids
);
const selectIsModalOpen = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.isModalOpen
@@ -32,8 +38,11 @@ const ChangeBoardModal = () => {
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
const isModalOpen = useAppSelector(selectIsModalOpen);
const imagesToChange = useAppSelector(selectImagesToChange);
const videosToChange = useAppSelector(selectVideosToChange);
const [addImagesToBoard] = useAddImagesToBoardMutation();
const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation();
const [addVideosToBoard] = useAddVideosToBoardMutation();
const [removeVideosFromBoard] = useRemoveVideosFromBoardMutation();
const { t } = useTranslation();
const options = useMemo<ComboboxOption[]>(() => {
@@ -57,20 +66,41 @@ const ChangeBoardModal = () => {
}, [dispatch]);
const handleChangeBoard = useCallback(() => {
if (!imagesToChange.length || !selectedBoardId) {
if (!selectedBoardId || (imagesToChange.length === 0 && videosToChange.length === 0)) {
return;
}
if (selectedBoardId === 'none') {
removeImagesFromBoard({ image_names: imagesToChange });
} else {
addImagesToBoard({
image_names: imagesToChange,
board_id: selectedBoardId,
});
if (imagesToChange.length) {
if (selectedBoardId === 'none') {
removeImagesFromBoard({ image_names: imagesToChange });
} else {
addImagesToBoard({
image_names: imagesToChange,
board_id: selectedBoardId,
});
}
}
if (videosToChange.length) {
if (selectedBoardId === 'none') {
removeVideosFromBoard({ video_ids: videosToChange });
} else {
addVideosToBoard({
video_ids: videosToChange,
board_id: selectedBoardId,
});
}
}
dispatch(changeBoardReset());
}, [addImagesToBoard, dispatch, imagesToChange, removeImagesFromBoard, selectedBoardId]);
}, [
addImagesToBoard,
dispatch,
imagesToChange,
videosToChange,
removeImagesFromBoard,
selectedBoardId,
addVideosToBoard,
removeVideosFromBoard,
]);
const onChange = useCallback<ComboboxOnChange>((v) => {
if (!v) {
@@ -91,9 +121,15 @@ const ChangeBoardModal = () => {
>
<Flex flexDir="column" gap={4}>
<Text>
{t('boards.movingImagesToBoard', {
count: imagesToChange.length,
})}
{imagesToChange.length > 0 &&
t('boards.movingImagesToBoard', {
count: imagesToChange.length,
})}
{videosToChange.length > 0 &&
t('boards.movingVideosToBoard', {
count: videosToChange.length,
})}
:
</Text>
<FormControl isDisabled={isFetching}>
<Combobox

View File

@@ -7,6 +7,7 @@ import z from 'zod';
const zChangeBoardModalState = z.object({
isModalOpen: z.boolean().default(false),
image_names: z.array(z.string()).default(() => []),
video_ids: z.array(z.string()).default(() => []),
});
type ChangeBoardModalState = z.infer<typeof zChangeBoardModalState>;
@@ -22,6 +23,9 @@ const slice = createSlice({
imagesToChangeSelected: (state, action: PayloadAction<string[]>) => {
state.image_names = action.payload;
},
videosToChangeSelected: (state, action: PayloadAction<string[]>) => {
state.video_ids = action.payload;
},
changeBoardReset: (state) => {
state.image_names = [];
state.isModalOpen = false;
@@ -29,7 +33,7 @@ const slice = createSlice({
},
});
export const { isModalOpenChanged, imagesToChangeSelected, changeBoardReset } = slice.actions;
export const { isModalOpenChanged, imagesToChangeSelected, videosToChangeSelected, changeBoardReset } = slice.actions;
export const selectChangeBoardModalSlice = (state: RootState) => state.changeBoardModal;

View File

@@ -1611,6 +1611,7 @@ const slice = createSlice({
state.bbox.rect.width = 1024;
state.bbox.rect.height = 1024;
}
syncScaledSize(state);
}
});

View File

@@ -391,7 +391,7 @@ const applyClipSkip = (state: { clipSkip: number }, model: ParameterModel | null
const maxClip = getModelMaxClipSkip(model);
state.clipSkip = clamp(clipSkip, 0, maxClip);
state.clipSkip = clamp(clipSkip, 0, maxClip ?? 0);
};
const hasModelClipSkip = (model: ParameterModel | null) => {
@@ -399,7 +399,7 @@ const hasModelClipSkip = (model: ParameterModel | null) => {
return false;
}
return getModelMaxClipSkip(model) > 0;
return getModelMaxClipSkip(model) ?? 0 > 0;
};
const getModelMaxClipSkip = (model: ParameterModel) => {
@@ -408,7 +408,7 @@ const getModelMaxClipSkip = (model: ParameterModel) => {
return 0;
}
return CLIP_SKIP_MAP[model.base].maxClip;
return CLIP_SKIP_MAP[model.base]?.maxClip;
};
const resetState = (state: ParamsState): ParamsState => {

View File

@@ -482,6 +482,33 @@ export const FLUX_KONTEXT_ASPECT_RATIOS: Record<FluxKontextAspectRatio, Dimensio
'1:1': { width: 1024, height: 1024 },
};
export const zVeo3AspectRatioID = z.enum(['16:9']);
type Veo3AspectRatio = z.infer<typeof zVeo3AspectRatioID>;
export const isVeo3AspectRatioID = (v: unknown): v is Veo3AspectRatio => zVeo3AspectRatioID.safeParse(v).success;
export const zRunwayAspectRatioID = z.enum(['16:9', '4:3', '1:1', '3:4', '9:16', '21:9']);
type RunwayAspectRatio = z.infer<typeof zRunwayAspectRatioID>;
export const isRunwayAspectRatioID = (v: unknown): v is RunwayAspectRatio => zRunwayAspectRatioID.safeParse(v).success;
export const zVideoAspectRatio = z.union([zVeo3AspectRatioID, zRunwayAspectRatioID]);
export type VideoAspectRatio = z.infer<typeof zVideoAspectRatio>;
export const isVideoAspectRatio = (v: unknown): v is VideoAspectRatio => zVideoAspectRatio.safeParse(v).success;
export const zVeo3Resolution = z.enum(['720p', '1080p']);
type Veo3Resolution = z.infer<typeof zVeo3Resolution>;
export const isVeo3Resolution = (v: unknown): v is Veo3Resolution => zVeo3Resolution.safeParse(v).success;
export const RESOLUTION_MAP: Record<Veo3Resolution | RunwayResolution, Dimensions> = {
'720p': { width: 1280, height: 720 },
'1080p': { width: 1920, height: 1080 },
};
export const zRunwayResolution = z.enum(['720p']);
type RunwayResolution = z.infer<typeof zRunwayResolution>;
export const isRunwayResolution = (v: unknown): v is RunwayResolution => zRunwayResolution.safeParse(v).success;
export const zVideoResolution = z.union([zVeo3Resolution, zRunwayResolution]);
export type VideoResolution = z.infer<typeof zVideoResolution>;
const zAspectRatioConfig = z.object({
id: zAspectRatioID,
value: z.number().gt(0),
@@ -495,6 +522,24 @@ export const DEFAULT_ASPECT_RATIO_CONFIG: AspectRatioConfig = {
isLocked: false,
};
const zVeo3DurationID = z.enum(['8']);
type Veo3Duration = z.infer<typeof zVeo3DurationID>;
export const isVeo3DurationID = (v: unknown): v is Veo3Duration => zVeo3DurationID.safeParse(v).success;
export const VEO3_DURATIONS: Record<Veo3Duration, string> = {
'8': '8 seconds',
};
const zRunwayDurationID = z.enum(['5', '10']);
type RunwayDuration = z.infer<typeof zRunwayDurationID>;
export const isRunwayDurationID = (v: unknown): v is RunwayDuration => zRunwayDurationID.safeParse(v).success;
export const RUNWAY_DURATIONS: Record<RunwayDuration, string> = {
'5': '5 seconds',
'10': '10 seconds',
};
export const zVideoDuration = z.union([zVeo3DurationID, zRunwayDurationID]);
export type VideoDuration = z.infer<typeof zVideoDuration>;
const zBboxState = z.object({
rect: z.object({
x: z.number().int(),

View File

@@ -0,0 +1,28 @@
import { useDeleteVideoModalApi } from 'features/deleteVideoModal/store/state';
import { useCallback, useMemo } from 'react';
import type { VideoDTO } from 'services/api/types';
export const useDeleteVideo = (videoDTO?: VideoDTO | null) => {
const deleteImageModal = useDeleteVideoModalApi();
const isEnabled = useMemo(() => {
if (!videoDTO) {
return;
}
return true;
}, [videoDTO]);
const _delete = useCallback(() => {
if (!videoDTO) {
return;
}
if (!isEnabled) {
return;
}
deleteImageModal.delete([videoDTO.video_id]);
}, [deleteImageModal, videoDTO, isEnabled]);
return {
delete: _delete,
isEnabled,
};
};

View File

@@ -12,7 +12,7 @@ import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasState, RefImagesState } from 'features/controlLayers/store/types';
import type { ImageUsage } from 'features/deleteImageModal/store/types';
import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { itemSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
@@ -89,9 +89,15 @@ const handleDeletions = async (image_names: string[], store: AppStore) => {
const newImageNames = data?.image_names.filter((name) => !deleted_images.includes(name)) || [];
const newSelectedImage = newImageNames[index ?? 0] || null;
if (intersection(state.gallery.selection, image_names).length > 0) {
if (
intersection(
state.gallery.selection.map((s) => s.id),
image_names
).length > 0 &&
newSelectedImage
) {
// Some selected images were deleted, clear selection
dispatch(imageSelected(newSelectedImage));
dispatch(itemSelected({ type: 'image', id: newSelectedImage }));
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist

View File

@@ -0,0 +1,36 @@
import type { IconButtonProps } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { selectSelectionCount } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { $isConnected } from 'services/events/stores';
type Props = Omit<IconButtonProps, 'aria-label'> & {
onClick: () => void;
};
export const DeleteVideoButton = memo((props: Props) => {
const { onClick, isDisabled } = props;
const { t } = useTranslation();
const isConnected = useStore($isConnected);
const count = useAppSelector(selectSelectionCount);
const labelMessage: string = `${t('gallery.deleteVideo', { count })} (Del)`;
return (
<IconButton
onClick={onClick}
icon={<PiTrashSimpleBold />}
tooltip={labelMessage}
aria-label={labelMessage}
isDisabled={isDisabled || !isConnected}
colorScheme="error"
variant="link"
alignSelf="stretch"
/>
);
});
DeleteVideoButton.displayName = 'DeleteVideoButton';

View File

@@ -0,0 +1,43 @@
import { ConfirmationAlertDialog, Flex, FormControl, FormLabel, Switch, Text } from '@invoke-ai/ui-library';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useDeleteVideoModalApi, useDeleteVideoModalState } from 'features/deleteVideoModal/store/state';
import { selectSystemShouldConfirmOnDelete, setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const DeleteVideoModal = memo(() => {
const state = useDeleteVideoModalState();
const api = useDeleteVideoModalApi();
const { dispatch } = useAppStore();
const { t } = useTranslation();
const shouldConfirmOnDelete = useAppSelector(selectSystemShouldConfirmOnDelete);
const handleChangeShouldConfirmOnDelete = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldConfirmOnDelete(!e.target.checked)),
[dispatch]
);
return (
<ConfirmationAlertDialog
title={`${t('gallery.deleteVideo', { count: state.video_ids.length })}`}
isOpen={state.isOpen}
onClose={api.close}
cancelButtonText={t('common.cancel')}
acceptButtonText={t('common.delete')}
acceptCallback={api.confirm}
cancelCallback={api.cancel}
useInert={false}
>
<Flex direction="column" gap={3}>
<Text>{t('gallery.deleteVideoPermanent')}</Text>
<Text>{t('common.areYouSure')}</Text>
<FormControl>
<FormLabel>{t('common.dontAskMeAgain')}</FormLabel>
<Switch isChecked={!shouldConfirmOnDelete} onChange={handleChangeShouldConfirmOnDelete} />
</FormControl>
</Flex>
</ConfirmationAlertDialog>
);
});
DeleteVideoModal.displayName = 'DeleteVideoModal';

View File

@@ -0,0 +1,111 @@
import { useStore } from '@nanostores/react';
import type { AppStore } from 'app/store/store';
import { useAppStore } from 'app/store/storeHooks';
import { intersection } from 'es-toolkit/compat';
import { selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors';
import { itemSelected } from 'features/gallery/store/gallerySlice';
import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { atom } from 'nanostores';
import { useMemo } from 'react';
import { videosApi } from 'services/api/endpoints/videos';
// Implements an awaitable modal dialog for deleting images
type DeleteVideosModalState = {
video_ids: string[];
isOpen: boolean;
resolve?: () => void;
reject?: (reason?: string) => void;
};
const getInitialState = (): DeleteVideosModalState => ({
video_ids: [],
isOpen: false,
});
const $deleteVideosModalState = atom<DeleteVideosModalState>(getInitialState());
const deleteVideosWithDialog = async (video_ids: string[], store: AppStore): Promise<void> => {
const { getState } = store;
const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState());
if (!shouldConfirmOnDelete) {
// If we don't need to confirm and the resources are not in use, delete them directly
await handleDeletions(video_ids, store);
return;
}
return new Promise<void>((resolve, reject) => {
$deleteVideosModalState.set({
video_ids,
isOpen: true,
resolve,
reject,
});
});
};
const handleDeletions = async (video_ids: string[], store: AppStore) => {
try {
const { dispatch, getState } = store;
const state = getState();
const { data } = videosApi.endpoints.getVideoIds.select(selectGetVideoIdsQueryArgs(state))(state);
const index = data?.video_ids.findIndex((id) => id === video_ids[0]);
const { deleted_videos } = await dispatch(
videosApi.endpoints.deleteVideos.initiate({ video_ids }, { track: false })
).unwrap();
const newVideoIds = data?.video_ids.filter((id) => !deleted_videos.includes(id)) || [];
const newSelectedVideoId = newVideoIds[index ?? 0] || null;
if (
intersection(
state.gallery.selection.map((s) => s.id),
video_ids
).length > 0 &&
newSelectedVideoId
) {
// Some selected images were deleted, clear selection
dispatch(itemSelected({ type: 'video', id: newSelectedVideoId }));
}
} catch {
// no-op
}
};
const confirmDeletion = async (store: AppStore) => {
const state = $deleteVideosModalState.get();
await handleDeletions(state.video_ids, store);
state.resolve?.();
closeSilently();
};
const cancelDeletion = () => {
const state = $deleteVideosModalState.get();
state.reject?.('User canceled');
closeSilently();
};
const closeSilently = () => {
$deleteVideosModalState.set(getInitialState());
};
export const useDeleteVideoModalState = () => {
const state = useStore($deleteVideosModalState);
return state;
};
export const useDeleteVideoModalApi = () => {
const store = useAppStore();
const api = useMemo(
() => ({
delete: (video_ids: string[]) => deleteVideosWithDialog(video_ids, store),
confirm: () => confirmDeletion(store),
cancel: cancelDeletion,
close: closeSilently,
}),
[store]
);
return api;
};

View File

@@ -21,7 +21,7 @@ const DndDragPreviewMultipleImage = memo(({ image_names }: { image_names: string
borderRadius="base"
>
<Heading>{image_names.length}</Heading>
<Heading size="sm">{t('parameters.images')}</Heading>
<Heading size="sm">{t('parameters.images_withCount', { count: image_names.length })}</Heading>
</Flex>
);
});

View File

@@ -0,0 +1,63 @@
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { Flex, Heading } from '@invoke-ai/ui-library';
import type { MultipleVideoDndSourceData } from 'features/dnd/dnd';
import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util';
import { memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Param0 } from 'tsafe';
const DndDragPreviewMultipleVideo = memo(({ video_ids }: { video_ids: string[] }) => {
const { t } = useTranslation();
return (
<Flex
w={DND_IMAGE_DRAG_PREVIEW_SIZE}
h={DND_IMAGE_DRAG_PREVIEW_SIZE}
alignItems="center"
justifyContent="center"
flexDir="column"
bg="base.900"
borderRadius="base"
>
<Heading>{video_ids.length}</Heading>
<Heading size="sm">{t('parameters.videos_withCount', { count: video_ids.length })}</Heading>
</Flex>
);
});
DndDragPreviewMultipleVideo.displayName = 'DndDragPreviewMultipleVideo';
export type DndDragPreviewMultipleVideoState = {
type: 'multiple-video';
container: HTMLElement;
video_ids: string[];
};
export const createMultipleVideoDragPreview = (arg: DndDragPreviewMultipleVideoState) =>
createPortal(<DndDragPreviewMultipleVideo video_ids={arg.video_ids} />, arg.container);
type SetMultipleDragPreviewArg = {
multipleVideoDndData: MultipleVideoDndSourceData;
setDragPreviewState: (dragPreviewState: DndDragPreviewMultipleVideoState | null) => void;
onGenerateDragPreviewArgs: Param0<Param0<typeof draggable>['onGenerateDragPreview']>;
};
export const setMultipleVideoDragPreview = ({
multipleVideoDndData,
onGenerateDragPreviewArgs,
setDragPreviewState,
}: SetMultipleDragPreviewArg) => {
const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
setCustomNativeDragPreview({
render({ container }) {
setDragPreviewState({ type: 'multiple-video', container, video_ids: multipleVideoDndData.payload.video_ids });
return () => setDragPreviewState(null);
},
nativeSetDragImage,
getOffset: preserveOffsetOnSourceFallbackCentered({
element: source.element,
input: location.current.input,
}),
});
};

View File

@@ -0,0 +1,69 @@
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { chakra, Flex } from '@invoke-ai/ui-library';
import type { SingleVideoDndSourceData } from 'features/dnd/dnd';
import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util';
import { GalleryVideoPlaceholder } from 'features/gallery/components/ImageGrid/GalleryVideoPlaceholder';
import { memo } from 'react';
import { createPortal } from 'react-dom';
import type { VideoDTO } from 'services/api/types';
import type { Param0 } from 'tsafe';
const ChakraImg = chakra('img');
const DndDragPreviewSingleVideo = memo(({ videoDTO }: { videoDTO: VideoDTO }) => {
return (
<Flex position="relative" w={DND_IMAGE_DRAG_PREVIEW_SIZE} h={DND_IMAGE_DRAG_PREVIEW_SIZE}>
<GalleryVideoPlaceholder />
<ChakraImg
position="absolute"
margin="auto"
maxW="full"
maxH="full"
objectFit="contain"
borderRadius="base"
borderWidth={2}
borderColor="invokeBlue.300"
borderStyle="solid"
cursor="grabbing"
src={videoDTO.thumbnail_url}
/>
</Flex>
);
});
DndDragPreviewSingleVideo.displayName = 'DndDragPreviewSingleVideo';
export type DndDragPreviewSingleVideoState = {
type: 'single-video';
container: HTMLElement;
videoDTO: VideoDTO;
};
export const createSingleVideoDragPreview = (arg: DndDragPreviewSingleVideoState) =>
createPortal(<DndDragPreviewSingleVideo videoDTO={arg.videoDTO} />, arg.container);
type SetSingleDragPreviewArg = {
singleVideoDndData: SingleVideoDndSourceData;
setDragPreviewState: (dragPreviewState: DndDragPreviewSingleVideoState | null) => void;
onGenerateDragPreviewArgs: Param0<Param0<typeof draggable>['onGenerateDragPreview']>;
};
export const setSingleVideoDragPreview = ({
singleVideoDndData,
onGenerateDragPreviewArgs,
setDragPreviewState,
}: SetSingleDragPreviewArg) => {
const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
setCustomNativeDragPreview({
render({ container }) {
setDragPreviewState({ type: 'single-video', container, videoDTO: singleVideoDndData.payload.videoDTO });
return () => setDragPreviewState(null);
},
nativeSetDragImage,
getOffset: preserveOffsetOnSourceFallbackCentered({
element: source.element,
input: location.current.input,
}),
});
};

View File

@@ -7,7 +7,7 @@ import { singleImageDndSource } from 'features/dnd/dnd';
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
import { firefoxDndFix } from 'features/dnd/util';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { useImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu';
import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ImageDTO } from 'services/api/types';

View File

@@ -9,9 +9,11 @@ import { selectComparisonImages } from 'features/gallery/components/ImageViewer/
import type { BoardId } from 'features/gallery/store/types';
import {
addImagesToBoard,
addVideosToBoard,
createNewCanvasEntityFromImage,
newCanvasFromImage,
removeImagesFromBoard,
removeVideosFromBoard,
replaceCanvasEntityObjectsWithImage,
setComparisonImage,
setGlobalReferenceImage,
@@ -22,9 +24,10 @@ 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 { startingFrameImageChanged } from 'features/parameters/store/videoSlice';
import { expandPrompt } from 'features/prompt/PromptExpansion/expand';
import { promptExpansionApi } from 'features/prompt/PromptExpansion/state';
import type { ImageDTO } from 'services/api/types';
import type { ImageDTO, VideoDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest';
const log = logger('dnd');
@@ -70,6 +73,35 @@ type DndSource<SourceData extends DndData> = {
typeGuard: ReturnType<typeof buildTypeGuard<SourceData>>;
getData: ReturnType<typeof buildGetData<SourceData>>;
};
//#region Single Video
const _singleVideo = buildTypeAndKey('single-video');
export type SingleVideoDndSourceData = DndData<
typeof _singleVideo.type,
typeof _singleVideo.key,
{ videoDTO: VideoDTO }
>;
export const singleVideoDndSource: DndSource<SingleVideoDndSourceData> = {
..._singleVideo,
typeGuard: buildTypeGuard(_singleVideo.key),
getData: buildGetData(_singleVideo.key, _singleVideo.type),
};
//#endregion
//#region Multiple Image
const _multipleVideo = buildTypeAndKey('multiple-video');
export type MultipleVideoDndSourceData = DndData<
typeof _multipleVideo.type,
typeof _multipleVideo.key,
{ video_ids: string[]; board_id: BoardId }
>;
export const multipleVideoDndSource: DndSource<MultipleVideoDndSourceData> = {
..._multipleVideo,
typeGuard: buildTypeGuard(_multipleVideo.key),
getData: buildGetData(_multipleVideo.key, _multipleVideo.type),
};
//#endregion
//#region Single Image
const _singleImage = buildTypeAndKey('single-image');
export type SingleImageDndSourceData = DndData<
@@ -443,12 +475,22 @@ export type AddImageToBoardDndTargetData = DndData<
>;
export const addImageToBoardDndTarget: DndTarget<
AddImageToBoardDndTargetData,
SingleImageDndSourceData | MultipleImageDndSourceData
SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | MultipleVideoDndSourceData
> = {
..._addToBoard,
typeGuard: buildTypeGuard(_addToBoard.key),
getData: buildGetData(_addToBoard.key, _addToBoard.type),
isValid: ({ sourceData, targetData }) => {
if (singleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none';
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
if (singleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
const destinationBoard = targetData.payload.boardId;
@@ -462,6 +504,18 @@ export const addImageToBoardDndTarget: DndTarget<
return false;
},
handler: ({ sourceData, targetData, dispatch }) => {
if (singleVideoDndSource.typeGuard(sourceData)) {
const { videoDTO } = sourceData.payload;
const { boardId } = targetData.payload;
addVideosToBoard({ video_ids: [videoDTO.video_id], boardId, dispatch });
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const { video_ids } = sourceData.payload;
const { boardId } = targetData.payload;
addVideosToBoard({ video_ids, boardId, dispatch });
}
if (singleImageDndSource.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
const { boardId } = targetData.payload;
@@ -487,7 +541,7 @@ export type RemoveImageFromBoardDndTargetData = DndData<
>;
export const removeImageFromBoardDndTarget: DndTarget<
RemoveImageFromBoardDndTargetData,
SingleImageDndSourceData | MultipleImageDndSourceData
SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | MultipleVideoDndSourceData
> = {
..._removeFromBoard,
typeGuard: buildTypeGuard(_removeFromBoard.key),
@@ -503,6 +557,16 @@ export const removeImageFromBoardDndTarget: DndTarget<
return currentBoard !== 'none';
}
if (singleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none';
return currentBoard !== 'none';
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
return currentBoard !== 'none';
}
return false;
},
handler: ({ sourceData, dispatch }) => {
@@ -515,6 +579,16 @@ export const removeImageFromBoardDndTarget: DndTarget<
const { image_names } = sourceData.payload;
removeImagesFromBoard({ image_names, dispatch });
}
if (singleVideoDndSource.typeGuard(sourceData)) {
const { videoDTO } = sourceData.payload;
removeVideosFromBoard({ video_ids: [videoDTO.video_id], dispatch });
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const { video_ids } = sourceData.payload;
removeVideosFromBoard({ video_ids, dispatch });
}
},
};
@@ -548,6 +622,30 @@ export const promptGenerationFromImageDndTarget: DndTarget<
};
//#endregion
//#region Video Frame From Image
const _videoFrameFromImage = buildTypeAndKey('video-frame-from-image');
type VideoFrameFromImageDndTargetData = DndData<
typeof _videoFrameFromImage.type,
typeof _videoFrameFromImage.key,
{ frame: 'start' | 'end' }
>;
export const videoFrameFromImageDndTarget: DndTarget<VideoFrameFromImageDndTargetData, SingleImageDndSourceData> = {
..._videoFrameFromImage,
typeGuard: buildTypeGuard(_videoFrameFromImage.key),
getData: buildGetData(_videoFrameFromImage.key, _videoFrameFromImage.type),
isValid: ({ sourceData }) => {
if (singleImageDndSource.typeGuard(sourceData)) {
return true;
}
return false;
},
handler: ({ sourceData, dispatch }) => {
const { imageDTO } = sourceData.payload;
dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO)));
},
};
//#endregion
export const dndTargets = [
setGlobalReferenceImageDndTarget,
addGlobalReferenceImageDndTarget,
@@ -562,6 +660,7 @@ export const dndTargets = [
addImageToBoardDndTarget,
removeImageFromBoardDndTarget,
promptGenerationFromImageDndTarget,
videoFrameFromImageDndTarget,
] as const;
export type AnyDndTarget = (typeof dndTargets)[number];

View File

@@ -4,7 +4,13 @@ import { logger } from 'app/logging/logger';
import { getStore } from 'app/store/nanostores/store';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { parseify } from 'common/util/serialize';
import { dndTargets, multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
import {
dndTargets,
multipleImageDndSource,
multipleVideoDndSource,
singleImageDndSource,
singleVideoDndSource,
} from 'features/dnd/dnd';
import { useEffect } from 'react';
const log = logger('dnd');
@@ -19,7 +25,12 @@ export const useDndMonitor = () => {
const sourceData = source.data;
// Check for allowed sources
if (!singleImageDndSource.typeGuard(sourceData) && !multipleImageDndSource.typeGuard(sourceData)) {
if (
!singleImageDndSource.typeGuard(sourceData) &&
!multipleImageDndSource.typeGuard(sourceData) &&
!singleVideoDndSource.typeGuard(sourceData) &&
!multipleVideoDndSource.typeGuard(sourceData)
) {
return false;
}

View File

@@ -1,26 +1,23 @@
import { Flex, Image, Text } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useTranslation } from 'react-i18next';
import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { BoardDTO } from 'services/api/types';
type Props = {
board: BoardDTO | null;
boardCounts: {
image_count: number;
asset_count: number;
video_count: number;
};
};
export const BoardTooltip = ({ board }: Props) => {
export const BoardTooltip = ({ board, boardCounts }: Props) => {
const { t } = useTranslation();
const { imagesTotal } = useGetBoardImagesTotalQuery(board?.board_id || 'none', {
selectFromResult: ({ data }) => {
return { imagesTotal: data?.total ?? 0 };
},
});
const { assetsTotal } = useGetBoardAssetsTotalQuery(board?.board_id || 'none', {
selectFromResult: ({ data }) => {
return { assetsTotal: data?.total ?? 0 };
},
});
const isVideoEnabled = useFeatureStatus('video');
const { currentData: coverImage } = useGetImageDTOQuery(board?.cover_image_name ?? skipToken);
return (
@@ -39,8 +36,10 @@ export const BoardTooltip = ({ board }: Props) => {
<Flex flexDir="column" alignItems="center">
{board && <Text fontWeight="semibold">{board.board_name}</Text>}
<Text noOfLines={1}>
{t('boards.imagesWithCount', { count: imagesTotal })}, {t('boards.assetsWithCount', { count: assetsTotal })}
{t('boards.imagesWithCount', { count: boardCounts.image_count })},{' '}
{t('boards.assetsWithCount', { count: boardCounts.asset_count })}
</Text>
{isVideoEnabled && <Text noOfLines={1}>{t('boards.videosWithCount', { count: boardCounts.video_count })}</Text>}
{board?.archived && <Text>({t('boards.archived')})</Text>}
</Flex>
</Flex>

View File

@@ -15,6 +15,7 @@ import {
selectSelectedBoardId,
} from 'features/gallery/store/gallerySelectors';
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArchiveBold, PiImageSquare } from 'react-icons/pi';
@@ -36,6 +37,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick);
const selectedBoardId = useAppSelector(selectSelectedBoardId);
const isVideoEnabled = useFeatureStatus('video');
const onClick = useCallback(() => {
if (selectedBoardId !== board.board_id) {
dispatch(boardIdSelected({ boardId: board.board_id }));
@@ -50,11 +52,26 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
[board.board_id]
);
const boardCounts = useMemo(
() => ({
image_count: board.image_count,
asset_count: board.asset_count,
video_count: board.video_count,
}),
[board]
);
return (
<Box position="relative" w="full" h={12}>
<BoardContextMenu board={board}>
{(ref) => (
<Tooltip label={<BoardTooltip board={board} />} openDelay={1000} placement="left" closeOnScroll p={2}>
<Tooltip
label={<BoardTooltip board={board} boardCounts={boardCounts} />}
openDelay={1000}
placement="right"
closeOnScroll
p={2}
>
<Flex
ref={ref}
onClick={onClick}
@@ -71,12 +88,17 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
h="full"
>
<CoverImage board={board} />
<Flex w="full">
<Flex flex={1}>
<BoardEditableTitle board={board} isSelected={isSelected} />
</Flex>
{autoAddBoardId === board.board_id && <AutoAddBadge />}
{board.archived && <Icon as={PiArchiveBold} fill="base.300" />}
<Text variant="subtext">{board.image_count}</Text>
<Flex justifyContent="flex-end">
<Text variant="subtext">
{board.image_count} | {isVideoEnabled && `${board.video_count} | `}
{board.asset_count}
</Text>
</Flex>
</Flex>
</Tooltip>
)}

View File

@@ -13,9 +13,14 @@ import {
selectBoardSearchText,
} from 'features/gallery/store/gallerySelectors';
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards';
import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
useGetBoardVideosTotalQuery,
} from 'services/api/endpoints/boards';
import { useBoardName } from 'services/api/hooks/useBoardName';
interface Props {
@@ -28,11 +33,23 @@ const _hover: SystemStyleObject = {
const NoBoardBoard = memo(({ isSelected }: Props) => {
const dispatch = useAppDispatch();
const isVideoEnabled = useFeatureStatus('video');
const { imagesTotal } = useGetBoardImagesTotalQuery('none', {
selectFromResult: ({ data }) => {
return { imagesTotal: data?.total ?? 0 };
},
});
const { assetsTotal } = useGetBoardAssetsTotalQuery('none', {
selectFromResult: ({ data }) => {
return { assetsTotal: data?.total ?? 0 };
},
});
const { videoTotal } = useGetBoardVideosTotalQuery('none', {
skip: !isVideoEnabled,
selectFromResult: ({ data }) => {
return { videoTotal: data?.total ?? 0 };
},
});
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick);
const boardSearchText = useAppSelector(selectBoardSearchText);
@@ -56,7 +73,17 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
<Box position="relative" w="full" h={12}>
<NoBoardBoardContextMenu>
{(ref) => (
<Tooltip label={<BoardTooltip board={null} />} openDelay={1000} placement="left" closeOnScroll>
<Tooltip
label={
<BoardTooltip
board={null}
boardCounts={{ image_count: imagesTotal, asset_count: assetsTotal, video_count: videoTotal }}
/>
}
openDelay={1000}
placement="right"
closeOnScroll
>
<Flex
ref={ref}
onClick={handleSelectBoard}
@@ -92,7 +119,10 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
{boardName}
</Text>
{autoAddBoardId === 'none' && <AutoAddBadge />}
<Text variant="subtext">{imagesTotal}</Text>
<Text variant="subtext">
{imagesTotal} | {isVideoEnabled && `${videoTotal} | `}
{assetsTotal}
</Text>
</Flex>
</Tooltip>
)}

View File

@@ -3,8 +3,8 @@ import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke-
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import MultipleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import MultipleSelectionMenuItems from 'features/gallery/components/ContextMenu/MultipleSelectionMenuItems';
import SingleSelectionMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionMenuItems';
import { selectSelectionCount } from 'features/gallery/store/gallerySelectors';
import { map } from 'nanostores';
import type { RefObject } from 'react';

View File

@@ -0,0 +1,35 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import {
imagesToChangeSelected,
isModalOpenChanged,
videosToChangeSelected,
} from 'features/changeBoardModal/store/slice';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFoldersBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ContextMenuItemChangeBoard = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const itemDTO = useItemDTOContext();
const onClick = useCallback(() => {
if (isImageDTO(itemDTO)) {
dispatch(imagesToChangeSelected([itemDTO.image_name]));
} else {
dispatch(videosToChangeSelected([itemDTO.video_id]));
}
dispatch(isModalOpenChanged(true));
}, [dispatch, itemDTO]);
return (
<MenuItem icon={<PiFoldersBold />} onClickCapture={onClick}>
{t('boards.changeBoard')}
</MenuItem>
);
});
ContextMenuItemChangeBoard.displayName = 'ContextMenuItemChangeBoard';

View File

@@ -1,18 +1,23 @@
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ImageMenuItemCopy = memo(() => {
export const ContextMenuItemCopy = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const copyImageToClipboard = useCopyImageToClipboard();
const onClick = useCallback(() => {
copyImageToClipboard(imageDTO.image_url);
}, [copyImageToClipboard, imageDTO.image_url]);
if (isImageDTO(itemDTO)) {
copyImageToClipboard(itemDTO.image_url);
} else {
// copyVideoToClipboard(itemDTO.video_url);
}
}, [copyImageToClipboard, itemDTO]);
return (
<IconMenuItem
@@ -24,4 +29,4 @@ export const ImageMenuItemCopy = memo(() => {
);
});
ImageMenuItemCopy.displayName = 'ImageMenuItemCopy';
ContextMenuItemCopy.displayName = 'ContextMenuItemCopy';

View File

@@ -1,22 +1,25 @@
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ImageMenuItemDelete = memo(() => {
export const ContextMenuItemDeleteImage = memo(() => {
const { t } = useTranslation();
const deleteImageModal = useDeleteImageModalApi();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const onClick = useCallback(async () => {
try {
await deleteImageModal.delete([imageDTO.image_name]);
if (isImageDTO(itemDTO)) {
await deleteImageModal.delete([itemDTO.image_name]);
}
} catch {
// noop;
}
}, [deleteImageModal, imageDTO]);
}, [deleteImageModal, itemDTO]);
return (
<IconMenuItem
@@ -29,4 +32,4 @@ export const ImageMenuItemDelete = memo(() => {
);
});
ImageMenuItemDelete.displayName = 'ImageMenuItemDelete';
ContextMenuItemDeleteImage.displayName = 'ContextMenuItemDeleteImage';

View File

@@ -0,0 +1,35 @@
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useDeleteVideoModalApi } from 'features/deleteVideoModal/store/state';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { isVideoDTO } from 'services/api/types';
export const ContextMenuItemDeleteVideo = memo(() => {
const { t } = useTranslation();
const deleteVideoModal = useDeleteVideoModalApi();
const itemDTO = useItemDTOContext();
const onClick = useCallback(async () => {
try {
if (isVideoDTO(itemDTO)) {
await deleteVideoModal.delete([itemDTO.video_id]);
}
} catch {
// noop;
}
}, [deleteVideoModal, itemDTO]);
return (
<IconMenuItem
icon={<PiTrashSimpleBold />}
onClickCapture={onClick}
aria-label={t('gallery.deleteVideo', { count: 1 })}
tooltip={t('gallery.deleteVideo', { count: 1 })}
isDestructive
/>
);
});
ContextMenuItemDeleteVideo.displayName = 'ContextMenuItemDeleteVideo';

View File

@@ -0,0 +1,32 @@
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useDownloadItem } from 'common/hooks/useDownloadImage';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ContextMenuItemDownload = memo(() => {
const { t } = useTranslation();
const itemDTO = useItemDTOContext();
const { downloadItem } = useDownloadItem();
const onClick = useCallback(() => {
if (isImageDTO(itemDTO)) {
downloadItem(itemDTO.image_url, itemDTO.image_name);
} else {
downloadItem(itemDTO.video_url, itemDTO.video_id);
}
}, [downloadItem, itemDTO]);
return (
<IconMenuItem
icon={<PiDownloadSimpleBold />}
aria-label={t('gallery.download')}
tooltip={t('gallery.download')}
onClick={onClick}
/>
);
});
ContextMenuItemDownload.displayName = 'ContextMenuItemDownload';

View File

@@ -0,0 +1,39 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFlowArrowBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ContextMenuItemLoadWorkflow = memo(() => {
const { t } = useTranslation();
const itemDTO = useItemDTOContext();
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const hasTemplates = useStore($hasTemplates);
const onClick = useCallback(() => {
if (isImageDTO(itemDTO)) {
loadWorkflowWithDialog({ type: 'image', data: itemDTO.image_name });
} else {
// loadWorkflowWithDialog({ type: 'video', data: itemDTO.video_id });
}
}, [loadWorkflowWithDialog, itemDTO]);
const isDisabled = useMemo(() => {
if (isImageDTO(itemDTO)) {
return !itemDTO.has_workflow || !hasTemplates;
}
return false;
}, [itemDTO, hasTemplates]);
return (
<MenuItem icon={<PiFlowArrowBold />} onClickCapture={onClick} isDisabled={isDisabled}>
{t('nodes.loadWorkflow')}
</MenuItem>
);
});
ContextMenuItemLoadWorkflow.displayName = 'ContextMenuItemLoadWorkflow';

View File

@@ -0,0 +1,63 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { PiCrosshairBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ContextMenuItemLocateInGalery = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const itemDTO = useItemDTOContext();
const activeTab = useAppSelector(selectActiveTab);
const galleryPanel = useGalleryPanel(activeTab);
const isGalleryImage = useMemo(() => {
return !itemDTO.is_intermediate;
}, [itemDTO]);
const onClick = useCallback(() => {
navigationApi.expandRightPanel();
galleryPanel.expand();
if (isImageDTO(itemDTO)) {
flushSync(() => {
dispatch(
boardIdSelected({
boardId: itemDTO.board_id ?? 'none',
select: {
selection: [{ type: 'image', id: itemDTO.image_name }],
galleryView: IMAGE_CATEGORIES.includes(itemDTO.image_category) ? 'images' : 'assets',
},
})
);
});
} else {
flushSync(() => {
dispatch(
boardIdSelected({
boardId: itemDTO.board_id ?? 'none',
select: {
selection: [{ type: 'video', id: itemDTO.video_id }],
galleryView: 'videos',
},
})
);
});
}
}, [dispatch, galleryPanel, itemDTO]);
return (
<MenuItem icon={<PiCrosshairBold />} onClickCapture={onClick} isDisabled={!isGalleryImage}>
{t('boards.locateInGalery')}
</MenuItem>
);
});
ContextMenuItemLocateInGalery.displayName = 'ContextMenuItemLocateInGalery';

View File

@@ -1,7 +1,7 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { useRecallAll } from 'features/gallery/hooks/useRecallAllImageMetadata';
import { useRecallCLIPSkip } from 'features/gallery/hooks/useRecallCLIPSkip';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
@@ -17,19 +17,21 @@ import {
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
export const ImageMenuItemMetadataRecallActionsCanvasGenerateTabs = memo(() => {
export const ContextMenuItemMetadataRecallActionsCanvasGenerateTabs = memo(() => {
const { t } = useTranslation();
const subMenu = useSubMenu();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const recallAll = useRecallAll(imageDTO);
const recallRemix = useRecallRemix(imageDTO);
const recallPrompts = useRecallPrompts(imageDTO);
const recallSeed = useRecallSeed(imageDTO);
const recallDimensions = useRecallDimensions(imageDTO);
const recallCLIPSkip = useRecallCLIPSkip(imageDTO);
// TODO: Implement video recall metadata actions
const recallAll = useRecallAll(itemDTO as ImageDTO);
const recallRemix = useRecallRemix(itemDTO as ImageDTO);
const recallPrompts = useRecallPrompts(itemDTO as ImageDTO);
const recallSeed = useRecallSeed(itemDTO as ImageDTO);
const recallDimensions = useRecallDimensions(itemDTO as ImageDTO);
const recallCLIPSkip = useRecallCLIPSkip(itemDTO as ImageDTO);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiArrowBendUpLeftBold />}>
@@ -66,5 +68,5 @@ export const ImageMenuItemMetadataRecallActionsCanvasGenerateTabs = memo(() => {
);
});
ImageMenuItemMetadataRecallActionsCanvasGenerateTabs.displayName =
'ImageMenuItemMetadataRecallActionsCanvasGenerateTabs';
ContextMenuItemMetadataRecallActionsCanvasGenerateTabs.displayName =
'ContextMenuItemMetadataRecallActionsCanvasGenerateTabs';

View File

@@ -1,20 +1,22 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowBendUpLeftBold, PiPlantBold, PiQuotesBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
export const ImageMenuItemMetadataRecallActionsUpscaleTab = memo(() => {
export const ContextMenuItemMetadataRecallActionsUpscaleTab = memo(() => {
const { t } = useTranslation();
const subMenu = useSubMenu();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const recallPrompts = useRecallPrompts(imageDTO);
const recallSeed = useRecallSeed(imageDTO);
// TODO: Implement video recall metadata actions
const recallPrompts = useRecallPrompts(itemDTO as ImageDTO);
const recallSeed = useRecallSeed(itemDTO as ImageDTO);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiArrowBendUpLeftBold />}>
@@ -35,4 +37,4 @@ export const ImageMenuItemMetadataRecallActionsUpscaleTab = memo(() => {
);
});
ImageMenuItemMetadataRecallActionsUpscaleTab.displayName = 'ImageMenuItemMetadataRecallActionsUpscaleTab';
ContextMenuItemMetadataRecallActionsUpscaleTab.displayName = 'ContextMenuItemMetadataRecallActionsUpscaleTab';

View File

@@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { newCanvasFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
@@ -12,11 +12,11 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFileBold, PiPlusBold } from 'react-icons/pi';
export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
export const ContextMenuItemNewCanvasFromImageSubMenu = memo(() => {
const { t } = useTranslation();
const subMenu = useSubMenu();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageDTO = useItemDTOContextImageOnly();
const isBusy = useCanvasIsBusySafe();
const isStaging = useCanvasIsStaging();
@@ -133,4 +133,4 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
);
});
ImageMenuItemNewCanvasFromImageSubMenu.displayName = 'ImageMenuItemNewCanvasFromImageSubMenu';
ContextMenuItemNewCanvasFromImageSubMenu.displayName = 'ContextMenuItemNewCanvasFromImageSubMenu';

View File

@@ -3,7 +3,7 @@ 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';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { sentImageToCanvas } from 'features/gallery/store/actions';
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
@@ -13,11 +13,11 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
export const ContextMenuItemNewLayerFromImageSubMenu = memo(() => {
const { t } = useTranslation();
const subMenu = useSubMenu();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageDTO = useItemDTOContextImageOnly();
const isBusy = useCanvasIsBusySafe();
const onClickNewRasterLayerFromImage = useCallback(async () => {
@@ -112,4 +112,4 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
);
});
ImageMenuItemNewLayerFromImageSubMenu.displayName = 'ImageMenuItemNewLayerFromImageSubMenu';
ContextMenuItemNewLayerFromImageSubMenu.displayName = 'ContextMenuItemNewLayerFromImageSubMenu';

View File

@@ -1,19 +1,24 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { imageOpenedInNewTab } from 'features/gallery/store/actions';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowSquareOutBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ImageMenuItemOpenInNewTab = memo(() => {
export const ContextMenuItemOpenInNewTab = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
dispatch(imageOpenedInNewTab());
}, [imageDTO.image_url, dispatch]);
if (isImageDTO(itemDTO)) {
window.open(itemDTO.image_url, '_blank');
dispatch(imageOpenedInNewTab());
} else {
window.open(itemDTO.video_url, '_blank');
}
}, [itemDTO, dispatch]);
return (
<IconMenuItem
@@ -25,4 +30,4 @@ export const ImageMenuItemOpenInNewTab = memo(() => {
);
});
ImageMenuItemOpenInNewTab.displayName = 'ImageMenuItemOpenInNewTab';
ContextMenuItemOpenInNewTab.displayName = 'ContextMenuItemOpenInNewTab';

View File

@@ -1,22 +1,27 @@
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 { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { imageToCompareChanged, itemSelected } 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';
import { isImageDTO } from 'services/api/types';
export const ImageMenuItemOpenInViewer = memo(() => {
export const ContextMenuItemOpenInViewer = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO.image_name));
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
}, [dispatch, imageDTO]);
if (isImageDTO(itemDTO)) {
dispatch(imageToCompareChanged(null));
dispatch(itemSelected({ type: 'image', id: itemDTO.image_name }));
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
} else {
// TODO: Implement video open in viewer
}
}, [dispatch, itemDTO]);
return (
<IconMenuItem
@@ -28,4 +33,4 @@ export const ImageMenuItemOpenInViewer = memo(() => {
);
});
ImageMenuItemOpenInViewer.displayName = 'ImageMenuItemOpenInViewer';
ContextMenuItemOpenInViewer.displayName = 'ContextMenuItemOpenInViewer';

View File

@@ -1,25 +1,36 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImagesBold } from 'react-icons/pi';
import { isImageDTO } from 'services/api/types';
export const ImageMenuItemSelectForCompare = memo(() => {
export const ContextMenuItemSelectForCompare = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const selectMaySelectForCompare = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare !== imageDTO.image_name),
[imageDTO.image_name]
() =>
createSelector(selectGallerySlice, (gallery) => {
if (isImageDTO(itemDTO)) {
return gallery.imageToCompare !== itemDTO.image_name;
}
return false;
}),
[itemDTO]
);
const maySelectForCompare = useAppSelector(selectMaySelectForCompare);
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(imageDTO.image_name));
}, [dispatch, imageDTO]);
if (isImageDTO(itemDTO)) {
dispatch(imageToCompareChanged(itemDTO.image_name));
} else {
// TODO: Implement video select for compare
}
}, [dispatch, itemDTO]);
return (
<IconMenuItem
@@ -32,4 +43,4 @@ export const ImageMenuItemSelectForCompare = memo(() => {
);
});
ImageMenuItemSelectForCompare.displayName = 'ImageMenuItemSelectForCompare';
ContextMenuItemSelectForCompare.displayName = 'ContextMenuItemSelectForCompare';

View File

@@ -1,7 +1,7 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
@@ -9,10 +9,10 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiShareFatBold } from 'react-icons/pi';
export const ImageMenuItemSendToUpscale = memo(() => {
export const ContextMenuItemSendToUpscale = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const imageDTO = useImageDTOContext();
const imageDTO = useItemDTOContextImageOnly();
const handleSendToCanvas = useCallback(() => {
dispatch(upscaleInitialImageChanged(imageDTOToImageWithDims(imageDTO)));
@@ -31,4 +31,4 @@ export const ImageMenuItemSendToUpscale = memo(() => {
);
});
ImageMenuItemSendToUpscale.displayName = 'ImageMenuItemSendToUpscale';
ContextMenuItemSendToUpscale.displayName = 'ContextMenuItemSendToUpscale';

View File

@@ -0,0 +1,27 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { startingFrameImageChanged } from 'features/parameters/store/videoSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiVideoBold } from 'react-icons/pi';
import { useDispatch } from 'react-redux';
export const ContextMenuItemSendToVideo = memo(() => {
const { t } = useTranslation();
const imageDTO = useItemDTOContextImageOnly();
const dispatch = useDispatch();
const onClick = useCallback(() => {
dispatch(startingFrameImageChanged(imageDTO));
navigationApi.switchToTab('video');
}, [imageDTO, dispatch]);
return (
<MenuItem icon={<PiVideoBold />} onClickCapture={onClick} aria-label={t('parameters.sendToVideo')}>
{t('parameters.sendToVideo')}
</MenuItem>
);
});
ContextMenuItemSendToVideo.displayName = 'ContextMenuItemSendToVideo';

View File

@@ -1,32 +1,40 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiStarBold, PiStarFill } from 'react-icons/pi';
import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
import { useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos';
import { isImageDTO, isVideoDTO } from 'services/api/types';
export const ImageMenuItemStarUnstar = memo(() => {
export const ContextMenuItemStarUnstar = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const itemDTO = useItemDTOContext();
const customStarUi = useStore($customStarUI);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
const [starVideos] = useStarVideosMutation();
const [unstarVideos] = useUnstarVideosMutation();
const starImage = useCallback(() => {
if (imageDTO) {
starImages({ image_names: [imageDTO.image_name] });
if (isImageDTO(itemDTO)) {
starImages({ image_names: [itemDTO.image_name] });
} else if (isVideoDTO(itemDTO)) {
starVideos({ video_ids: [itemDTO.video_id] });
}
}, [starImages, imageDTO]);
}, [starImages, itemDTO, starVideos]);
const unstarImage = useCallback(() => {
if (imageDTO) {
unstarImages({ image_names: [imageDTO.image_name] });
if (isImageDTO(itemDTO)) {
unstarImages({ image_names: [itemDTO.image_name] });
} else if (isVideoDTO(itemDTO)) {
unstarVideos({ video_ids: [itemDTO.video_id] });
}
}, [unstarImages, imageDTO]);
}, [unstarImages, itemDTO, unstarVideos]);
if (imageDTO.starred) {
if (itemDTO.starred) {
return (
<MenuItem icon={customStarUi ? customStarUi.off.icon : <PiStarFill />} onClickCapture={unstarImage}>
{customStarUi ? customStarUi.off.text : t('gallery.unstarImage')}
@@ -41,4 +49,4 @@ export const ImageMenuItemStarUnstar = memo(() => {
);
});
ImageMenuItemStarUnstar.displayName = 'ImageMenuItemStarUnstar';
ContextMenuItemStarUnstar.displayName = 'ContextMenuItemStarUnstar';

View File

@@ -1,13 +1,14 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { useCreateStylePresetFromMetadata } from 'features/gallery/hooks/useCreateStylePresetFromMetadata';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPaintBrushBold } from 'react-icons/pi';
export const ImageMenuItemUseAsPromptTemplate = memo(() => {
export const ContextMenuItemUseAsPromptTemplate = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const imageDTO = useItemDTOContextImageOnly();
const stylePreset = useCreateStylePresetFromMetadata(imageDTO);
return (
@@ -17,4 +18,4 @@ export const ImageMenuItemUseAsPromptTemplate = memo(() => {
);
});
ImageMenuItemUseAsPromptTemplate.displayName = 'ImageMenuItemUseAsPromptTemplate';
ContextMenuItemUseAsPromptTemplate.displayName = 'ContextMenuItemUseAsPromptTemplate';

View File

@@ -3,16 +3,16 @@ 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';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold } from 'react-icons/pi';
export const ImageMenuItemUseAsRefImage = memo(() => {
export const ContextMenuItemUseAsRefImage = memo(() => {
const { t } = useTranslation();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageDTO = useItemDTOContextImageOnly();
const onClickNewGlobalReferenceImageFromImage = useCallback(() => {
const { dispatch, getState } = store;
@@ -33,4 +33,4 @@ export const ImageMenuItemUseAsRefImage = memo(() => {
);
});
ImageMenuItemUseAsRefImage.displayName = 'ImageMenuItemUseAsRefImage';
ContextMenuItemUseAsRefImage.displayName = 'ContextMenuItemUseAsRefImage';

View File

@@ -1,7 +1,7 @@
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 { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { expandPrompt } from 'features/prompt/PromptExpansion/expand';
import { promptExpansionApi } from 'features/prompt/PromptExpansion/state';
import { selectAllowPromptExpansion } from 'features/system/store/configSlice';
@@ -10,10 +10,10 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTextTBold } from 'react-icons/pi';
export const ImageMenuItemUseForPromptGeneration = memo(() => {
export const ContextMenuItemUseForPromptGeneration = memo(() => {
const { t } = useTranslation();
const { dispatch, getState } = useAppStore();
const imageDTO = useImageDTOContext();
const imageDTO = useItemDTOContextImageOnly();
const { isPending } = useStore(promptExpansionApi.$state);
const isPromptExpansionEnabled = useAppSelector(selectAllowPromptExpansion);
@@ -43,4 +43,4 @@ export const ImageMenuItemUseForPromptGeneration = memo(() => {
);
});
ImageMenuItemUseForPromptGeneration.displayName = 'ImageMenuItemUseForPromptGeneration';
ContextMenuItemUseForPromptGeneration.displayName = 'ContextMenuItemUseForPromptGeneration';

View File

@@ -28,24 +28,24 @@ const MultipleSelectionMenuItems = () => {
const [bulkDownload] = useBulkDownloadImagesMutation();
const handleChangeBoard = useCallback(() => {
dispatch(imagesToChangeSelected(selection));
dispatch(imagesToChangeSelected(selection.map((s) => s.id)));
dispatch(isModalOpenChanged(true));
}, [dispatch, selection]);
const handleDeleteSelection = useCallback(() => {
deleteImageModal.delete(selection);
deleteImageModal.delete(selection.map((s) => s.id));
}, [deleteImageModal, selection]);
const handleStarSelection = useCallback(() => {
starImages({ image_names: selection });
starImages({ image_names: selection.map((s) => s.id) });
}, [starImages, selection]);
const handleUnstarSelection = useCallback(() => {
unstarImages({ image_names: selection });
unstarImages({ image_names: selection.map((s) => s.id) });
}, [unstarImages, selection]);
const handleBulkDownload = useCallback(() => {
bulkDownload({ image_names: selection });
bulkDownload({ image_names: selection.map((s) => s.id) });
}, [selection, bulkDownload]);
return (

View File

@@ -0,0 +1,58 @@
import { MenuDivider, MenuItem } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi';
import { useDeleteVideosMutation, useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos';
const MultipleSelectionMenuItems = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const selection = useAppSelector((s) => s.gallery.selection);
const customStarUi = useStore($customStarUI);
const [starVideos] = useStarVideosMutation();
const [unstarVideos] = useUnstarVideosMutation();
const [deleteVideos] = useDeleteVideosMutation();
const handleChangeBoard = useCallback(() => {
dispatch(videosToChangeSelected(selection.map((s) => s.id)));
dispatch(isModalOpenChanged(true));
}, [dispatch, selection]);
const handleDeleteSelection = useCallback(() => {
// TODO: Add confirm on delete and video usage functionality
deleteVideos({ video_ids: selection.map((s) => s.id) });
}, [deleteVideos, selection]);
const handleStarSelection = useCallback(() => {
starVideos({ video_ids: selection.map((s) => s.id) });
}, [starVideos, selection]);
const handleUnstarSelection = useCallback(() => {
unstarVideos({ video_ids: selection.map((s) => s.id) });
}, [unstarVideos, selection]);
return (
<>
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarBold />} onClickCapture={handleUnstarSelection}>
{customStarUi ? customStarUi.off.text : `Unstar All`}
</MenuItem>
<MenuItem icon={customStarUi ? customStarUi.on.icon : <PiStarFill />} onClickCapture={handleStarSelection}>
{customStarUi ? customStarUi.on.text : `Star All`}
</MenuItem>
<MenuItem icon={<PiFoldersBold />} onClickCapture={handleChangeBoard}>
{t('boards.changeBoard')}
</MenuItem>
<MenuDivider />
<MenuItem color="error.300" icon={<PiTrashSimpleBold />} onClickCapture={handleDeleteSelection}>
{t('gallery.deleteSelection')}
</MenuItem>
</>
);
};
export default memo(MultipleSelectionMenuItems);

View File

@@ -0,0 +1,71 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { IconMenuItemGroup } from 'common/components/IconMenuItem';
import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard';
import { ContextMenuItemCopy } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy';
import { ContextMenuItemDownload } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload';
import { ContextMenuItemLoadWorkflow } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow';
import { ContextMenuItemLocateInGalery } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery';
import { ContextMenuItemMetadataRecallActionsCanvasGenerateTabs } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsCanvasGenerateTabs';
import { ContextMenuItemNewCanvasFromImageSubMenu } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu';
import { ContextMenuItemNewLayerFromImageSubMenu } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu';
import { ContextMenuItemOpenInNewTab } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab';
import { ContextMenuItemOpenInViewer } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer';
import { ContextMenuItemSelectForCompare } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare';
import { ContextMenuItemSendToUpscale } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale';
import { ContextMenuItemSendToVideo } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo';
import { ContextMenuItemStarUnstar } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar';
import { ContextMenuItemUseAsPromptTemplate } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate';
import { ContextMenuItemUseAsRefImage } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage';
import { ContextMenuItemUseForPromptGeneration } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseForPromptGeneration';
import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import type { ImageDTO } from 'services/api/types';
import { ContextMenuItemDeleteImage } from './MenuItems/ContextMenuItemDeleteImage';
import { ContextMenuItemMetadataRecallActionsUpscaleTab } from './MenuItems/ContextMenuItemMetadataRecallActionsUpscaleTab';
type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO;
};
const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) => {
const tab = useAppSelector(selectActiveTab);
const isVideoEnabled = useFeatureStatus('video');
return (
<ItemDTOContextProvider value={imageDTO}>
<IconMenuItemGroup>
<ContextMenuItemOpenInNewTab />
<ContextMenuItemCopy />
<ContextMenuItemDownload />
<ContextMenuItemOpenInViewer />
<ContextMenuItemSelectForCompare />
<ContextMenuItemDeleteImage />
</IconMenuItemGroup>
<MenuDivider />
<ContextMenuItemLoadWorkflow />
{(tab === 'canvas' || tab === 'generate') && <ContextMenuItemMetadataRecallActionsCanvasGenerateTabs />}
{tab === 'upscaling' && <ContextMenuItemMetadataRecallActionsUpscaleTab />}
<MenuDivider />
<ContextMenuItemSendToUpscale />
{isVideoEnabled && <ContextMenuItemSendToVideo />}
<ContextMenuItemUseForPromptGeneration />
{(tab === 'canvas' || tab === 'generate') && <ContextMenuItemUseAsRefImage />}
<ContextMenuItemUseAsPromptTemplate />
<ContextMenuItemNewCanvasFromImageSubMenu />
{tab === 'canvas' && <ContextMenuItemNewLayerFromImageSubMenu />}
<MenuDivider />
<ContextMenuItemChangeBoard />
<ContextMenuItemStarUnstar />
{(tab === 'canvas' || tab === 'generate' || tab === 'workflows' || tab === 'upscaling') &&
!imageDTO.is_intermediate && (
// Only render this button on tabs with a gallery.
<ContextMenuItemLocateInGalery />
)}
</ItemDTOContextProvider>
);
};
export default SingleSelectionMenuItems;

View File

@@ -0,0 +1,33 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { IconMenuItemGroup } from 'common/components/IconMenuItem';
import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard';
import { ContextMenuItemDownload } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload';
import { ContextMenuItemOpenInNewTab } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab';
import { ContextMenuItemOpenInViewer } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer';
import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext';
import type { VideoDTO } from 'services/api/types';
import { ContextMenuItemDeleteVideo } from './MenuItems/ContextMenuItemDeleteVideo';
import { ContextMenuItemStarUnstar } from './MenuItems/ContextMenuItemStarUnstar';
type SingleSelectionVideoMenuItemsProps = {
videoDTO: VideoDTO;
};
const SingleSelectionVideoMenuItems = ({ videoDTO }: SingleSelectionVideoMenuItemsProps) => {
return (
<ItemDTOContextProvider value={videoDTO}>
<IconMenuItemGroup>
<ContextMenuItemOpenInNewTab />
<ContextMenuItemDownload />
<ContextMenuItemOpenInViewer />
<ContextMenuItemDeleteVideo />
</IconMenuItemGroup>
<MenuDivider />
<ContextMenuItemStarUnstar />
<ContextMenuItemChangeBoard />
</ItemDTOContextProvider>
);
};
export default SingleSelectionVideoMenuItems;

View File

@@ -0,0 +1,279 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import MultipleSelectionVideoMenuItems from 'features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems';
import SingleSelectionVideoMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems';
import { selectSelectionCount } from 'features/gallery/store/gallerySelectors';
import { map } from 'nanostores';
import type { RefObject } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import type { VideoDTO } from 'services/api/types';
/**
* The delay in milliseconds before the context menu opens on long press.
*/
const LONGPRESS_DELAY_MS = 500;
/**
* The threshold in pixels that the pointer must move before the long press is cancelled.
*/
const LONGPRESS_MOVE_THRESHOLD_PX = 10;
/**
* The singleton state of the context menu.
*/
const $videoContextMenuState = map<{
isOpen: boolean;
videoDTO: VideoDTO | null;
position: { x: number; y: number };
}>({
isOpen: false,
videoDTO: null,
position: { x: -1, y: -1 },
});
/**
* Convenience function to close the context menu.
*/
const onClose = () => {
$videoContextMenuState.setKey('isOpen', false);
};
/**
* Map of elements to image DTOs. This is used to determine which image DTO to show the context menu for, depending on
* the target of the context menu or long press event.
*/
const elToVideoMap = new Map<HTMLElement, VideoDTO>();
/**
* Given a target node, find the first registered parent element that contains the target node and return the imageDTO
* associated with it.
*/
const getVideoDTOFromMap = (target: Node): VideoDTO | undefined => {
const entry = Array.from(elToVideoMap.entries()).find((entry) => entry[0].contains(target));
return entry?.[1];
};
/**
* Register a context menu for an image DTO on a target element.
* @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 useVideoContextMenu = (videoDTO: VideoDTO, ref: RefObject<HTMLElement> | (HTMLElement | null)) => {
useEffect(() => {
if (ref === null) {
return;
}
const el = ref instanceof HTMLElement ? ref : ref.current;
if (!el) {
return;
}
elToVideoMap.set(el, videoDTO);
return () => {
elToVideoMap.delete(el);
};
}, [videoDTO, ref]);
};
/**
* Singleton component that renders the context menu for images.
*/
export const VideoContextMenu = memo(() => {
useAssertSingleton('VideoContextMenu');
const state = useStore($videoContextMenuState);
useGlobalMenuClose(onClose);
return (
<Portal>
<Menu isOpen={state.isOpen} gutter={0} placement="auto-end" onClose={onClose}>
<MenuButton
aria-hidden={true}
w={1}
h={1}
position="absolute"
left={state.position.x}
top={state.position.y}
cursor="default"
bg="transparent"
_hover={_hover}
pointerEvents="none"
/>
<MenuContent />
</Menu>
<VideoContextMenuEventLogical />
</Portal>
);
});
VideoContextMenu.displayName = 'VideoContextMenu';
const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
/**
* A logical component that listens for context menu events and opens the context menu. It's separate from
* ImageContextMenu component to avoid re-rendering the whole context menu on every context menu event.
*/
const VideoContextMenuEventLogical = memo(() => {
const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 });
const longPressTimeoutRef = useRef(0);
const animationTimeoutRef = useRef(0);
const onContextMenu = useCallback((e: MouseEvent | PointerEvent) => {
if (e.shiftKey) {
// This is a shift + right click event, which should open the native context menu
onClose();
return;
}
const videoDTO = getVideoDTOFromMap(e.target as Node);
if (!videoDTO) {
// Can't find the image DTO, close the context menu
onClose();
return;
}
// clear pending delayed open
window.clearTimeout(animationTimeoutRef.current);
e.preventDefault();
if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) {
// if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
if ($videoContextMenuState.get().isOpen) {
onClose();
}
animationTimeoutRef.current = window.setTimeout(() => {
// Open the menu after the animation with the new state
$videoContextMenuState.set({
isOpen: true,
position: { x: e.pageX, y: e.pageY },
videoDTO,
});
}, 100);
} else {
// else we can just open the menu at the current position w/ new state
$videoContextMenuState.set({
isOpen: true,
position: { x: e.pageX, y: e.pageY },
videoDTO,
});
}
// Always sync the last position
lastPositionRef.current = { x: e.pageX, y: e.pageY };
}, []);
// Use a long press to open the context menu on touch devices
const onPointerDown = useCallback(
(e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
longPressTimeoutRef.current = window.setTimeout(() => {
onContextMenu(e);
}, LONGPRESS_DELAY_MS);
lastPositionRef.current = { x: e.pageX, y: e.pageY };
},
[onContextMenu]
);
const onPointerMove = useCallback((e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
if (longPressTimeoutRef.current === null) {
return;
}
// If the pointer has moved more than the threshold, cancel the long press
const lastPosition = lastPositionRef.current;
const distanceFromLastPosition = Math.hypot(e.pageX - lastPosition.x, e.pageY - lastPosition.y);
if (distanceFromLastPosition > LONGPRESS_MOVE_THRESHOLD_PX) {
clearTimeout(longPressTimeoutRef.current);
}
}, []);
const onPointerUp = useCallback((e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
}
}, []);
const onPointerCancel = useCallback((e: PointerEvent) => {
if (e.pointerType === 'mouse') {
// Bail out if it's a mouse event - this is for touch/pen only
return;
}
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
}
}, []);
useEffect(() => {
const controller = new AbortController();
// Context menu events
window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal });
// Long press events
window.addEventListener('pointerdown', onPointerDown, { signal: controller.signal });
window.addEventListener('pointerup', onPointerUp, { signal: controller.signal });
window.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal });
window.addEventListener('pointermove', onPointerMove, { signal: controller.signal });
return () => {
controller.abort();
};
}, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
useEffect(
() => () => {
// Clean up any timeouts when we unmount
window.clearTimeout(animationTimeoutRef.current);
window.clearTimeout(longPressTimeoutRef.current);
},
[]
);
return null;
});
VideoContextMenuEventLogical.displayName = 'VideoContextMenuEventLogical';
// The content of the context menu, which changes based on the selection count. Split out and memoized to avoid
// re-rendering the whole context menu too often.
const MenuContent = memo(() => {
const selectionCount = useAppSelector(selectSelectionCount);
const state = useStore($videoContextMenuState);
if (!state.videoDTO) {
return null;
}
if (selectionCount > 1) {
return (
<MenuList visibility="visible">
<MultipleSelectionVideoMenuItems />
</MenuList>
);
}
return (
<MenuList visibility="visible">
<SingleSelectionVideoMenuItems videoDTO={state.videoDTO} />
</MenuList>
);
});
MenuContent.displayName = 'MenuContent';

View File

@@ -6,6 +6,7 @@ import { useDisclosure } from 'common/hooks/useBoolean';
import { useGallerySearchTerm } from 'features/gallery/components/ImageGrid/useGallerySearchTerm';
import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { galleryViewChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel';
import type { CSSProperties } from 'react';
@@ -17,7 +18,8 @@ import { useBoardName } from 'services/api/hooks/useBoardName';
import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover';
import { GalleryUploadButton } from './GalleryUploadButton';
import { GallerySearch } from './ImageGrid/GallerySearch';
import { NewGallery } from './NewGallery';
import { ImageGallery } from './NewGallery';
import { VideoGallery } from './VideoGallery';
const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '100%' };
@@ -42,6 +44,10 @@ export const GalleryPanel = memo(() => {
dispatch(galleryViewChanged('assets'));
}, [dispatch]);
const handleClickVideos = useCallback(() => {
dispatch(galleryViewChanged('videos'));
}, [dispatch]);
const handleClickSearch = useCallback(() => {
onResetSearchTerm();
if (!searchDisclosure.isOpen && galleryPanel.$isCollapsed.get()) {
@@ -52,6 +58,7 @@ export const GalleryPanel = memo(() => {
const selectedBoardId = useAppSelector(selectSelectedBoardId);
const boardName = useBoardName(selectedBoardId);
const isVideoEnabled = useFeatureStatus('video');
return (
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full" minH={0}>
@@ -75,6 +82,17 @@ export const GalleryPanel = memo(() => {
>
{t('parameters.images')}
</Button>
{isVideoEnabled && (
<Button
tooltip={t('gallery.videosTab')}
onClick={handleClickVideos}
data-testid="videos-tab"
colorScheme={galleryView === 'videos' ? 'invokeBlue' : undefined}
>
{t('gallery.videos')}
</Button>
)}
<Button
tooltip={t('gallery.assetsTab')}
onClick={handleClickAssets}
@@ -109,7 +127,7 @@ export const GalleryPanel = memo(() => {
</Collapse>
<Divider pt={2} />
<Flex w="full" h="full" pt={2}>
<NewGallery />
{galleryView === 'videos' ? <VideoGallery /> : <ImageGallery />}
</Flex>
</Flex>
);

View File

@@ -1,26 +0,0 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFoldersBold } from 'react-icons/pi';
export const ImageMenuItemChangeBoard = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const imageDTO = useImageDTOContext();
const onClick = useCallback(() => {
dispatch(imagesToChangeSelected([imageDTO.image_name]));
dispatch(isModalOpenChanged(true));
}, [dispatch, imageDTO]);
return (
<MenuItem icon={<PiFoldersBold />} onClickCapture={onClick}>
{t('boards.changeBoard')}
</MenuItem>
);
});
ImageMenuItemChangeBoard.displayName = 'ImageMenuItemChangeBoard';

View File

@@ -1,27 +0,0 @@
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useDownloadImage } from 'common/hooks/useDownloadImage';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold } from 'react-icons/pi';
export const ImageMenuItemDownload = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const { downloadImage } = useDownloadImage();
const onClick = useCallback(() => {
downloadImage(imageDTO.image_url, imageDTO.image_name);
}, [downloadImage, imageDTO.image_name, imageDTO.image_url]);
return (
<IconMenuItem
icon={<PiDownloadSimpleBold />}
aria-label={t('parameters.downloadImage')}
tooltip={t('parameters.downloadImage')}
onClick={onClick}
/>
);
});
ImageMenuItemDownload.displayName = 'ImageMenuItemDownload';

View File

@@ -1,27 +0,0 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFlowArrowBold } from 'react-icons/pi';
export const ImageMenuItemLoadWorkflow = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const hasTemplates = useStore($hasTemplates);
const onClick = useCallback(() => {
loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name });
}, [loadWorkflowWithDialog, imageDTO.image_name]);
return (
<MenuItem icon={<PiFlowArrowBold />} onClickCapture={onClick} isDisabled={!imageDTO.has_workflow || !hasTemplates}>
{t('nodes.loadWorkflow')}
</MenuItem>
);
});
ImageMenuItemLoadWorkflow.displayName = 'ImageMenuItemLoadWorkflow';

View File

@@ -1,39 +0,0 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { PiCrosshairBold } from 'react-icons/pi';
export const ImageMenuItemLocateInGalery = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const imageDTO = useImageDTOContext();
const activeTab = useAppSelector(selectActiveTab);
const galleryPanel = useGalleryPanel(activeTab);
const isGalleryImage = useMemo(() => {
return !imageDTO.is_intermediate;
}, [imageDTO]);
const onClick = useCallback(() => {
navigationApi.expandRightPanel();
galleryPanel.expand();
flushSync(() => {
dispatch(boardIdSelected({ boardId: imageDTO.board_id ?? 'none', selectedImageName: imageDTO.image_name }));
});
}, [dispatch, galleryPanel, imageDTO]);
return (
<MenuItem icon={<PiCrosshairBold />} onClickCapture={onClick} isDisabled={!isGalleryImage}>
{t('boards.locateInGalery')}
</MenuItem>
);
});
ImageMenuItemLocateInGalery.displayName = 'ImageMenuItemLocateInGalery';

View File

@@ -1,68 +0,0 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { IconMenuItemGroup } from 'common/components/IconMenuItem';
import { ImageMenuItemChangeBoard } from 'features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard';
import { ImageMenuItemCopy } from 'features/gallery/components/ImageContextMenu/ImageMenuItemCopy';
import { ImageMenuItemDelete } from 'features/gallery/components/ImageContextMenu/ImageMenuItemDelete';
import { ImageMenuItemDownload } from 'features/gallery/components/ImageContextMenu/ImageMenuItemDownload';
import { ImageMenuItemLoadWorkflow } from 'features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow';
import { ImageMenuItemLocateInGalery } from 'features/gallery/components/ImageContextMenu/ImageMenuItemLocateInGalery';
import { ImageMenuItemMetadataRecallActionsCanvasGenerateTabs } from 'features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsCanvasGenerateTabs';
import { ImageMenuItemNewCanvasFromImageSubMenu } from 'features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu';
import { ImageMenuItemNewLayerFromImageSubMenu } from 'features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu';
import { ImageMenuItemOpenInNewTab } from 'features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab';
import { ImageMenuItemOpenInViewer } from 'features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer';
import { ImageMenuItemSelectForCompare } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare';
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 { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import type { ImageDTO } from 'services/api/types';
import { ImageMenuItemMetadataRecallActionsUpscaleTab } from './ImageMenuItemMetadataRecallActionsUpscaleTab';
import { ImageMenuItemUseAsPromptTemplate } from './ImageMenuItemUseAsPromptTemplate';
type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO;
};
const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) => {
const tab = useAppSelector(selectActiveTab);
return (
<ImageDTOContextProvider value={imageDTO}>
<IconMenuItemGroup>
<ImageMenuItemOpenInNewTab />
<ImageMenuItemCopy />
<ImageMenuItemDownload />
<ImageMenuItemOpenInViewer />
<ImageMenuItemSelectForCompare />
<ImageMenuItemDelete />
</IconMenuItemGroup>
<MenuDivider />
<ImageMenuItemLoadWorkflow />
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemMetadataRecallActionsCanvasGenerateTabs />}
{tab === 'upscaling' && <ImageMenuItemMetadataRecallActionsUpscaleTab />}
<MenuDivider />
<ImageMenuItemSendToUpscale />
<ImageMenuItemUseForPromptGeneration />
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemUseAsRefImage />}
<ImageMenuItemUseAsPromptTemplate />
<ImageMenuItemNewCanvasFromImageSubMenu />
{tab === 'canvas' && <ImageMenuItemNewLayerFromImageSubMenu />}
<MenuDivider />
<ImageMenuItemChangeBoard />
<ImageMenuItemStarUnstar />
{(tab === 'canvas' || tab === 'generate' || tab === 'workflows' || tab === 'upscaling') &&
!imageDTO.is_intermediate && (
// Only render this button on tabs with a gallery.
<ImageMenuItemLocateInGalery />
)}
</ImageDTOContextProvider>
);
};
export default memo(SingleSelectionMenuItems);

View File

@@ -1,6 +1,6 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library';
import type { FlexProps } from '@invoke-ai/ui-library';
import { Flex, Icon, Image } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import type { AppDispatch, AppGetState } from 'app/store/store';
@@ -12,8 +12,8 @@ import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'fea
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
import { firefoxDndFix } from 'features/dnd/util';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons';
import { useImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu';
import { GalleryItemHoverIcons } from 'features/gallery/components/ImageGrid/GalleryItemHoverIcons';
import {
selectGetImageNamesQueryArgs,
selectSelectedBoardId,
@@ -28,56 +28,7 @@ import { PiImageBold } from 'react-icons/pi';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
const galleryImageContainerSX = {
containerType: 'inline-size',
w: 'full',
h: 'full',
'.gallery-image-size-badge': {
'@container (max-width: 80px)': {
'&': { display: 'none' },
},
},
'&[data-is-dragging=true]': {
opacity: 0.3,
},
userSelect: 'none',
webkitUserSelect: 'none',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
'::before': {
content: '""',
display: 'inline-block',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
borderRadius: 'base',
},
'&[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
'&:hover::before': {
boxShadow:
'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
} satisfies SystemStyleObject;
import { galleryItemContainerSX } from './galleryItemContainerSX';
interface Props {
imageDTO: ImageDTO;
@@ -95,7 +46,7 @@ const buildOnClick =
if (imageNames.length === 0) {
// For basic click without modifiers, we can still set selection
if (!shiftKey && !ctrlKey && !metaKey && !altKey) {
dispatch(selectionChanged([imageName]));
dispatch(selectionChanged([{ type: 'image', id: imageName }]));
}
return;
}
@@ -110,7 +61,7 @@ const buildOnClick =
}
} else if (shiftKey) {
const rangeEndImageName = imageName;
const lastSelectedImage = selection.at(-1);
const lastSelectedImage = selection.at(-1)?.id;
const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage);
const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
@@ -121,16 +72,16 @@ const buildOnClick =
if (currentClickedIndex < lastClickedIndex) {
imagesToSelect.reverse();
}
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect))));
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect.map((name) => ({ type: 'image', id: name }))))));
}
} else if (ctrlKey || metaKey) {
if (selection.some((n) => n === imageName) && selection.length > 1) {
dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName))));
if (selection.some((n) => n.id === imageName) && selection.length > 1) {
dispatch(selectionChanged(uniq(selection.filter((n) => n.id !== imageName))));
} else {
dispatch(selectionChanged(uniq(selection.concat(imageName))));
dispatch(selectionChanged(uniq(selection.concat({ type: 'image', id: imageName }))));
}
} else {
dispatch(selectionChanged([imageName]));
dispatch(selectionChanged([{ type: 'image', id: imageName }]));
}
};
@@ -147,7 +98,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
);
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
const selectIsSelected = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.selection.includes(imageDTO.image_name)),
() => createSelector(selectGallerySlice, (gallery) => gallery.selection.some((s) => s.id === imageDTO.image_name)),
[imageDTO.image_name]
);
const isSelected = useAppSelector(selectIsSelected);
@@ -164,11 +115,12 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
getInitialData: () => {
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 (selection.length > 1 && selection.includes(imageDTO.image_name)) {
if (selection.length > 1 && selection.some((s) => s.id === imageDTO.image_name)) {
return multipleImageDndSource.getData({
image_names: selection,
image_names: selection.map((s) => s.id),
board_id: boardId,
});
}
@@ -244,9 +196,9 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
<>
<Flex
ref={ref}
sx={galleryImageContainerSX}
sx={galleryItemContainerSX}
data-is-dragging={isDragging}
data-image-name={imageDTO.image_name}
data-item-id={imageDTO.image_name}
role="button"
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
@@ -265,7 +217,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
maxH="full"
borderRadius="base"
/>
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered} />
<GalleryItemHoverIcons itemDTO={imageDTO} isHovered={isHovered} />
</Flex>
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}
{dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null}

View File

@@ -1,28 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
import { GalleryImageDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryImageDeleteIconButton';
import { GalleryImageOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton';
import { GalleryImageSizeBadge } from 'features/gallery/components/ImageGrid/GalleryImageSizeBadge';
import { GalleryImageStarIconButton } from 'features/gallery/components/ImageGrid/GalleryImageStarIconButton';
import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import type { ImageDTO } from 'services/api/types';
type Props = {
imageDTO: ImageDTO;
isHovered: boolean;
};
export const GalleryImageHoverIcons = memo(({ imageDTO, isHovered }: Props) => {
const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
return (
<>
{(isHovered || alwaysShowImageSizeBadge) && <GalleryImageSizeBadge imageDTO={imageDTO} />}
{(isHovered || imageDTO.starred) && <GalleryImageStarIconButton imageDTO={imageDTO} />}
{isHovered && <GalleryImageDeleteIconButton imageDTO={imageDTO} />}
{isHovered && <GalleryImageOpenInViewerIconButton imageDTO={imageDTO} />}
</>
);
});
GalleryImageHoverIcons.displayName = 'GalleryImageHoverIcons';

View File

@@ -1,51 +0,0 @@
import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { memo, useCallback } from 'react';
import { PiStarBold, PiStarFill } from 'react-icons/pi';
import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
type Props = {
imageDTO: ImageDTO;
};
export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => {
const customStarUi = useStore($customStarUI);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
const toggleStarredState = useCallback(() => {
if (imageDTO.starred) {
unstarImages({ image_names: [imageDTO.image_name] });
} else {
starImages({ image_names: [imageDTO.image_name] });
}
}, [starImages, unstarImages, imageDTO]);
if (customStarUi) {
return (
<DndImageIcon
onClick={toggleStarredState}
icon={imageDTO.starred ? customStarUi.on.icon : customStarUi.off.icon}
tooltip={imageDTO.starred ? customStarUi.on.text : customStarUi.off.text}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
}
return (
<DndImageIcon
onClick={toggleStarredState}
icon={imageDTO.starred ? <PiStarFill /> : <PiStarBold />}
tooltip={imageDTO.starred ? 'Unstar' : 'Star'}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
});
GalleryImageStarIconButton.displayName = 'GalleryImageStarIconButton';

View File

@@ -5,26 +5,33 @@ import type { MouseEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
import { useDeleteVideosMutation } from 'services/api/endpoints/videos';
import { type ImageDTO, isImageDTO, type VideoDTO } from 'services/api/types';
type Props = {
imageDTO: ImageDTO;
itemDTO: ImageDTO | VideoDTO;
};
export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
export const GalleryItemDeleteIconButton = memo(({ itemDTO }: Props) => {
const shift = useShiftModifier();
const { t } = useTranslation();
const deleteImageModal = useDeleteImageModalApi();
const [deleteVideos] = useDeleteVideosMutation();
const onClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!imageDTO) {
if (!itemDTO) {
return;
}
deleteImageModal.delete([imageDTO.image_name]);
if (isImageDTO(itemDTO)) {
deleteImageModal.delete([itemDTO.image_name]);
} else {
// TODO: Add confirm on delete and video usage functionality
deleteVideos({ video_ids: [itemDTO.video_id] });
}
},
[deleteImageModal, imageDTO]
[deleteImageModal, deleteVideos, itemDTO]
);
if (!shift) {
@@ -43,4 +50,4 @@ export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
);
});
GalleryImageDeleteIconButton.displayName = 'GalleryImageDeleteIconButton';
GalleryItemDeleteIconButton.displayName = 'GalleryItemDeleteIconButton';

View File

@@ -0,0 +1,28 @@
import { useAppSelector } from 'app/store/storeHooks';
import { GalleryItemDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryItemDeleteIconButton';
import { GalleryItemOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton';
import { GalleryItemSizeBadge } from 'features/gallery/components/ImageGrid/GalleryItemSizeBadge';
import { GalleryItemStarIconButton } from 'features/gallery/components/ImageGrid/GalleryItemStarIconButton';
import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import type { ImageDTO, VideoDTO } from 'services/api/types';
type Props = {
itemDTO: ImageDTO | VideoDTO;
isHovered: boolean;
};
export const GalleryItemHoverIcons = memo(({ itemDTO, isHovered }: Props) => {
const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
return (
<>
{(isHovered || alwaysShowImageSizeBadge) && <GalleryItemSizeBadge itemDTO={itemDTO} />}
{(isHovered || itemDTO.starred) && <GalleryItemStarIconButton itemDTO={itemDTO} />}
{isHovered && <GalleryItemDeleteIconButton itemDTO={itemDTO} />}
{isHovered && <GalleryItemOpenInViewerIconButton itemDTO={itemDTO} />}
</>
);
});
GalleryItemHoverIcons.displayName = 'GalleryItemHoverIcons';

View File

@@ -1,26 +1,31 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
import { imageToCompareChanged, itemSelected } 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';
import type { ImageDTO } from 'services/api/types';
import { type ImageDTO, isImageDTO, type VideoDTO } from 'services/api/types';
type Props = {
imageDTO: ImageDTO;
itemDTO: ImageDTO | VideoDTO;
};
export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => {
export const GalleryItemOpenInViewerIconButton = memo(({ itemDTO }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const onClick = useCallback(() => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO.image_name));
if (isImageDTO(itemDTO)) {
dispatch(imageToCompareChanged(null));
dispatch(itemSelected({ type: 'image', id: itemDTO.image_name }));
} else {
// dispatch(videoToCompareChanged(null));
// dispatch(videoSelected(itemDTO.video_id));
}
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
}, [dispatch, imageDTO]);
}, [dispatch, itemDTO]);
return (
<DndImageIcon
@@ -34,4 +39,4 @@ export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) =>
);
});
GalleryImageOpenInViewerIconButton.displayName = 'GalleryImageOpenInViewerIconButton';
GalleryItemOpenInViewerIconButton.displayName = 'GalleryItemOpenInViewerIconButton';

View File

@@ -1,12 +1,12 @@
import { Text } from '@invoke-ai/ui-library';
import { memo } from 'react';
import type { ImageDTO } from 'services/api/types';
import type { ImageDTO, VideoDTO } from 'services/api/types';
type Props = {
imageDTO: ImageDTO;
itemDTO: ImageDTO | VideoDTO;
};
export const GalleryImageSizeBadge = memo(({ imageDTO }: Props) => {
export const GalleryItemSizeBadge = memo(({ itemDTO }: Props) => {
return (
<Text
className="gallery-image-size-badge"
@@ -22,8 +22,8 @@ export const GalleryImageSizeBadge = memo(({ imageDTO }: Props) => {
lineHeight={1.25}
borderTopEndRadius="base"
pointerEvents="none"
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
>{`${itemDTO.width}x${itemDTO.height}`}</Text>
);
});
GalleryImageSizeBadge.displayName = 'GalleryImageSizeBadge';
GalleryItemSizeBadge.displayName = 'GalleryItemSizeBadge';

View File

@@ -0,0 +1,62 @@
import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { memo, useCallback } from 'react';
import { PiStarBold, PiStarFill } from 'react-icons/pi';
import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
import { useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos';
import { type ImageDTO, isImageDTO, type VideoDTO } from 'services/api/types';
type Props = {
itemDTO: ImageDTO | VideoDTO;
};
export const GalleryItemStarIconButton = memo(({ itemDTO }: Props) => {
const customStarUi = useStore($customStarUI);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
const [starVideos] = useStarVideosMutation();
const [unstarVideos] = useUnstarVideosMutation();
const toggleStarredState = useCallback(() => {
if (itemDTO.starred) {
if (isImageDTO(itemDTO)) {
unstarImages({ image_names: [itemDTO.image_name] });
} else {
unstarVideos({ video_ids: [itemDTO.video_id] });
}
} else {
if (isImageDTO(itemDTO)) {
starImages({ image_names: [itemDTO.image_name] });
} else {
starVideos({ video_ids: [itemDTO.video_id] });
}
}
}, [starImages, unstarImages, starVideos, unstarVideos, itemDTO]);
if (customStarUi) {
return (
<DndImageIcon
onClick={toggleStarredState}
icon={itemDTO.starred ? customStarUi.on.icon : customStarUi.off.icon}
tooltip={itemDTO.starred ? customStarUi.on.text : customStarUi.off.text}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
}
return (
<DndImageIcon
onClick={toggleStarredState}
icon={itemDTO.starred ? <PiStarFill /> : <PiStarBold />}
tooltip={itemDTO.starred ? 'Unstar' : 'Star'}
position="absolute"
top={2}
insetInlineEnd={2}
/>
);
});
GalleryItemStarIconButton.displayName = 'GalleryItemStarIconButton';

View File

@@ -2,7 +2,7 @@ import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names';
import { selectFirstSelectedImage, selectSelectionCount } from 'features/gallery/store/gallerySelectors';
import { selectFirstSelectedItem, selectSelectionCount } from 'features/gallery/store/gallerySelectors';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo, useCallback } from 'react';
@@ -15,7 +15,8 @@ export const GallerySelectionCountTag = memo(() => {
const isGalleryFocused = useIsRegionFocused('gallery');
const onSelectPage = useCallback(() => {
dispatch(selectionChanged([...imageNames]));
const selection = imageNames.map((name) => ({ type: 'image' as const, id: name }));
dispatch(selectionChanged(selection));
}, [dispatch, imageNames]);
useRegisteredHotkeys({
@@ -39,13 +40,13 @@ const GallerySelectionCountTagContent = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isGalleryFocused = useIsRegionFocused('gallery');
const firstImage = useAppSelector(selectFirstSelectedImage);
const firstItem = useAppSelector(selectFirstSelectedItem);
const selectionCount = useAppSelector(selectSelectionCount);
const onClearSelection = useCallback(() => {
if (firstImage) {
dispatch(selectionChanged([firstImage]));
if (firstItem) {
dispatch(selectionChanged([firstItem]));
}
}, [dispatch, firstImage]);
}, [dispatch, firstItem]);
useRegisteredHotkeys({
id: 'clearSelection',

View File

@@ -0,0 +1,218 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { Flex, Image } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import type { AppDispatch, AppGetState } from 'app/store/store';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { uniq } from 'es-toolkit';
import { multipleVideoDndSource, singleVideoDndSource } from 'features/dnd/dnd';
import type { DndDragPreviewMultipleVideoState } from 'features/dnd/DndDragPreviewMultipleVideo';
import { createMultipleVideoDragPreview, setMultipleVideoDragPreview } from 'features/dnd/DndDragPreviewMultipleVideo';
import type { DndDragPreviewSingleVideoState } from 'features/dnd/DndDragPreviewSingleVideo';
import { createSingleVideoDragPreview, setSingleVideoDragPreview } from 'features/dnd/DndDragPreviewSingleVideo';
import { firefoxDndFix } from 'features/dnd/util';
import { useVideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu';
import {
selectGetVideoIdsQueryArgs,
selectSelectedBoardId,
selectSelection,
} from 'features/gallery/store/gallerySelectors';
import { imageToCompareChanged, selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice';
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, useMemo, useRef, useState } from 'react';
import { videosApi } from 'services/api/endpoints/videos';
import type { VideoDTO } from 'services/api/types';
import { galleryItemContainerSX } from './galleryItemContainerSX';
import { GalleryItemHoverIcons } from './GalleryItemHoverIcons';
import { GalleryVideoPlaceholder } from './GalleryVideoPlaceholder';
interface Props {
videoDTO: VideoDTO;
}
const buildOnClick =
(videoId: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent<HTMLDivElement>) => {
const { shiftKey, ctrlKey, metaKey, altKey } = e;
const state = getState();
const queryArgs = selectGetVideoIdsQueryArgs(state);
const videoIds = videosApi.endpoints.getVideoIds.select(queryArgs)(state).data?.video_ids ?? [];
// If we don't have the video ids cached, we can't perform selection operations
// This can happen if the user clicks on a video before the ids are loaded
if (videoIds.length === 0) {
// For basic click without modifiers, we can still set selection
if (!shiftKey && !ctrlKey && !metaKey && !altKey) {
dispatch(selectionChanged([{ type: 'video', id: videoId }]));
}
return;
}
const selection = state.gallery.selection;
if (altKey) {
if (state.gallery.imageToCompare === videoId) {
dispatch(imageToCompareChanged(null));
} else {
dispatch(imageToCompareChanged(videoId));
}
} else if (shiftKey) {
const rangeEndVideoId = videoId;
const lastSelectedVideo = selection.at(-1)?.id;
const lastClickedIndex = videoIds.findIndex((id) => id === lastSelectedVideo);
const currentClickedIndex = videoIds.findIndex((id) => id === rangeEndVideoId);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const videosToSelect = videoIds.slice(start, end + 1);
dispatch(selectionChanged(uniq(selection.concat(videosToSelect.map((id) => ({ type: 'video', id }))))));
}
} else if (ctrlKey || metaKey) {
if (selection.some((n) => n.id === videoId) && selection.length > 1) {
dispatch(selectionChanged(uniq(selection.filter((n) => n.id !== videoId))));
} else {
dispatch(selectionChanged(uniq(selection.concat({ type: 'video', id: videoId }))));
}
} else {
dispatch(selectionChanged([{ type: 'video', id: videoId }]));
}
};
export const GalleryVideo = memo(({ videoDTO }: Props) => {
const store = useAppStore();
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewState, setDragPreviewState] = useState<
DndDragPreviewSingleVideoState | DndDragPreviewMultipleVideoState | null
>(null);
const ref = useRef<HTMLDivElement>(null);
const selectIsSelected = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.selection.some((s) => s.id === videoDTO.video_id)),
[videoDTO.video_id]
);
const isSelected = useAppSelector(selectIsSelected);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return combine(
firefoxDndFix(element),
draggable({
element,
getInitialData: () => {
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 (selection.length > 1 && selection.some((s) => s.id === videoDTO.video_id)) {
return multipleVideoDndSource.getData({
video_ids: selection.map((s) => s.id),
board_id: boardId,
});
} // Otherwise, initiate a single-image drag
return singleVideoDndSource.getData({ videoDTO }, videoDTO.video_id);
},
// This is a "local" drag start event, meaning that it is only called when this specific image is dragged.
onDragStart: ({ source }) => {
// When we start dragging a single image, set the dragging state to true. This is only called when this
// specific image is dragged.
if (singleVideoDndSource.typeGuard(source.data)) {
setIsDragging(true);
return;
}
},
onGenerateDragPreview: (args) => {
if (multipleVideoDndSource.typeGuard(args.source.data)) {
setMultipleVideoDragPreview({
multipleVideoDndData: args.source.data,
onGenerateDragPreviewArgs: args,
setDragPreviewState,
});
} else if (singleVideoDndSource.typeGuard(args.source.data)) {
setSingleVideoDragPreview({
singleVideoDndData: args.source.data,
onGenerateDragPreviewArgs: args,
setDragPreviewState,
});
}
},
}),
monitorForElements({
// This is a "global" drag start event, meaning that it is called for all drag events.
onDragStart: ({ source }) => {
// When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
// selection. This is called for all drag events.
if (
multipleVideoDndSource.typeGuard(source.data) &&
source.data.payload.video_ids.includes(videoDTO.video_id)
) {
setIsDragging(true);
}
},
onDrop: () => {
// Always set the dragging state to false when a drop event occurs.
setIsDragging(false);
},
})
);
}, [videoDTO, store]);
const [isHovered, setIsHovered] = useState(false);
const onMouseOver = useCallback(() => {
setIsHovered(true);
}, []);
const onMouseOut = useCallback(() => {
setIsHovered(false);
}, []);
const onClick = useMemo(() => buildOnClick(videoDTO.video_id, store.dispatch, store.getState), [videoDTO, store]);
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(() => {
store.dispatch(imageToCompareChanged(null));
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
}, [store]);
useVideoContextMenu(videoDTO, ref);
return (
<>
<Flex
ref={ref}
sx={galleryItemContainerSX}
data-is-dragging={isDragging}
data-item-id={videoDTO.video_id}
role="button"
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={onClick}
onDoubleClick={onDoubleClick}
data-selected={isSelected}
data-selected-for-compare={false}
>
<Image
pointerEvents="none"
src={videoDTO.thumbnail_url}
w={videoDTO.width}
fallback={<GalleryVideoPlaceholder />}
objectFit="contain"
maxW="full"
maxH="full"
borderRadius="base"
/>
<GalleryItemHoverIcons itemDTO={videoDTO} isHovered={isHovered} />
</Flex>
{dragPreviewState?.type === 'multiple-video' ? createMultipleVideoDragPreview(dragPreviewState) : null}
{dragPreviewState?.type === 'single-video' ? createSingleVideoDragPreview(dragPreviewState) : null}
</>
);
});
GalleryVideo.displayName = 'GalleryVideo';

View File

@@ -0,0 +1,11 @@
import { Flex, type FlexProps, Icon } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { PiVideoBold } from 'react-icons/pi';
export const GalleryVideoPlaceholder = memo((props: FlexProps) => (
<Flex w="full" h="full" bg="base.850" borderRadius="base" alignItems="center" justifyContent="center" {...props}>
<Icon as={PiVideoBold} boxSize={16} color="base.800" />
</Flex>
));
GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder';

View File

@@ -0,0 +1,52 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
export const galleryItemContainerSX = {
containerType: 'inline-size',
w: 'full',
h: 'full',
'.gallery-image-size-badge': {
'@container (max-width: 80px)': {
'&': { display: 'none' },
},
},
'&[data-is-dragging=true]': {
opacity: 0.3,
},
userSelect: 'none',
webkitUserSelect: 'none',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
'::before': {
content: '""',
display: 'inline-block',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
borderRadius: 'base',
},
'&[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
'&:hover::before': {
boxShadow:
'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
},
'&:hover[data-selected-for-compare=true]::before': {
boxShadow:
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
},
} satisfies SystemStyleObject;

View File

@@ -9,10 +9,11 @@ import type {
UnrecallableMetadataHandler,
} from 'features/metadata/parsing';
import {
MetadataHandlers,
ImageMetadataHandlers,
useCollectionMetadataDatum,
useSingleMetadataDatum,
useUnrecallableMetadataDatum,
VideoMetadataHandlers,
} from 'features/metadata/parsing';
import { memo, useCallback } from 'react';
import { PiArrowBendUpLeftBold } from 'react-icons/pi';
@@ -21,7 +22,7 @@ type Props = {
metadata?: unknown;
};
const ImageMetadataActions = (props: Props) => {
export const ImageMetadataActions = memo((props: Props) => {
const { metadata } = props;
if (!metadata || Object.keys(metadata).length === 0) {
@@ -30,38 +31,60 @@ const ImageMetadataActions = (props: Props) => {
return (
<Flex flexDir="column" ps={8}>
<UnrecallableMetadataDatum metadata={metadata} handler={MetadataHandlers.GenerationMode} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.PositivePrompt} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.NegativePrompt} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.MainModel} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.VAEModel} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.Width} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.Height} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.Seed} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.Steps} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.Scheduler} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.CLIPSkip} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.CFGScale} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.CFGRescaleMultiplier} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.Guidance} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.DenoisingStrength} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.SeamlessX} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.SeamlessY} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.RefinerModel} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.RefinerCFGScale} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.RefinerPositiveAestheticScore} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.RefinerNegativeAestheticScore} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.RefinerScheduler} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.RefinerDenoisingStart} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.RefinerSteps} />
<SingleMetadataDatum metadata={metadata} handler={MetadataHandlers.CanvasLayers} />
<CollectionMetadataDatum metadata={metadata} handler={MetadataHandlers.RefImages} />
<CollectionMetadataDatum metadata={metadata} handler={MetadataHandlers.LoRAs} />
<UnrecallableMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.GenerationMode} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.PositivePrompt} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.NegativePrompt} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.MainModel} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.VAEModel} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.Width} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.Height} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.Seed} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.Steps} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.Scheduler} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.CLIPSkip} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.CFGScale} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.CFGRescaleMultiplier} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.Guidance} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.DenoisingStrength} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.SeamlessX} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.SeamlessY} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefinerModel} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefinerCFGScale} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefinerPositiveAestheticScore} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefinerNegativeAestheticScore} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefinerScheduler} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefinerDenoisingStart} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefinerSteps} />
<SingleMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.CanvasLayers} />
<CollectionMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.RefImages} />
<CollectionMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.LoRAs} />
</Flex>
);
};
});
export default memo(ImageMetadataActions);
ImageMetadataActions.displayName = 'ImageMetadataActions';
export const VideoMetadataActions = memo((props: Props) => {
const { metadata } = props;
if (!metadata || Object.keys(metadata).length === 0) {
return null;
}
return (
<Flex flexDir="column" ps={8}>
<UnrecallableMetadataDatum metadata={metadata} handler={VideoMetadataHandlers.GenerationMode} />
<SingleMetadataDatum metadata={metadata} handler={VideoMetadataHandlers.PositivePrompt} />
<SingleMetadataDatum metadata={metadata} handler={VideoMetadataHandlers.Seed} />
<SingleMetadataDatum metadata={metadata} handler={VideoMetadataHandlers.VideoAspectRatio} />
<SingleMetadataDatum metadata={metadata} handler={VideoMetadataHandlers.VideoDuration} />
<SingleMetadataDatum metadata={metadata} handler={VideoMetadataHandlers.VideoResolution} />
<SingleMetadataDatum metadata={metadata} handler={VideoMetadataHandlers.VideoModel} />
</Flex>
);
});
VideoMetadataActions.displayName = 'VideoMetadataActions';
export const UnrecallableMetadataDatum = typedMemo(
<T,>({ metadata, handler }: { metadata: unknown; handler: UnrecallableMetadataHandler<T> }) => {

View File

@@ -2,14 +2,14 @@ import { ExternalLink, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@in
import { IAINoContentFallback, IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import ImageMetadataGraphTabContent from 'features/gallery/components/ImageMetadataViewer/ImageMetadataGraphTabContent';
import { MetadataHandlers } from 'features/metadata/parsing';
import { ImageMetadataHandlers } from 'features/metadata/parsing';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
import DataViewer from './DataViewer';
import ImageMetadataActions, { UnrecallableMetadataDatum } from './ImageMetadataActions';
import { ImageMetadataActions, UnrecallableMetadataDatum } from './ImageMetadataActions';
import ImageMetadataWorkflowTabContent from './ImageMetadataWorkflowTabContent';
type ImageMetadataViewerProps = {
@@ -39,7 +39,7 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
overflow="hidden"
>
<ExternalLink href={image.image_url} label={image.image_name} />
<UnrecallableMetadataDatum metadata={metadata} handler={MetadataHandlers.CreatedBy} />
<UnrecallableMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.CreatedBy} />
<Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
<TabList>

View File

@@ -0,0 +1,80 @@
import { ExternalLink, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { IAINoContentFallback, IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { ImageMetadataHandlers } from 'features/metadata/parsing';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedVideoMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { VideoDTO } from 'services/api/types';
import DataViewer from './DataViewer';
import { UnrecallableMetadataDatum, VideoMetadataActions } from './ImageMetadataActions';
type VideoMetadataViewerProps = {
video: VideoDTO;
};
const VideoMetadataViewer = ({ video }: VideoMetadataViewerProps) => {
const { t } = useTranslation();
const { metadata, isLoading } = useDebouncedVideoMetadata(video.video_id);
return (
<Flex
layerStyle="first"
padding={4}
gap={1}
flexDirection="column"
width="full"
height="full"
borderRadius="base"
position="absolute"
overflow="hidden"
>
<ExternalLink href={video.video_url} label={video.video_id} />
<UnrecallableMetadataDatum metadata={metadata} handler={ImageMetadataHandlers.CreatedBy} />
<Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
<TabList>
<Tab>{t('metadata.recallParameters')}</Tab>
<Tab>{t('metadata.metadata')}</Tab>
<Tab>{t('metadata.videoDetails')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
{isLoading && <IAINoContentFallbackWithSpinner label="Loading metadata..." />}
{metadata && !isLoading && (
<ScrollableContent>
<VideoMetadataActions metadata={metadata} />
</ScrollableContent>
)}
{!metadata && !isLoading && <IAINoContentFallback label={t('metadata.noRecallParameters')} />}
</TabPanel>
<TabPanel>
{metadata ? (
<DataViewer fileName={`${video.video_id}_metadata`} data={metadata} label={t('metadata.metadata')} />
) : (
<IAINoContentFallback label={t('metadata.noMetaData')} />
)}
</TabPanel>
<TabPanel>
{video ? (
<DataViewer fileName={`${video.video_id}_details`} data={video} label={t('metadata.videoDetails')} />
) : (
<IAINoContentFallback label={t('metadata.noVideoDetails')} />
)}
</TabPanel>
{/* <TabPanel>
<ImageMetadataWorkflowTabContent image={image} />
</TabPanel>
<TabPanel>
<ImageMetadataGraphTabContent image={image} />
</TabPanel> */}
</TabPanels>
</Tabs>
</Flex>
);
};
export default memo(VideoMetadataViewer);

View File

@@ -1,16 +1,17 @@
import { Button, Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import SingleSelectionMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionMenuItems';
import { useDeleteImage } from 'features/gallery/hooks/useDeleteImage';
import { useEditImage } from 'features/gallery/hooks/useEditImage';
import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow';
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useRecallAll } from 'features/gallery/hooks/useRecallAllImageMetadata';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { navigationApi } from 'features/ui/layouts/navigation-api';
@@ -47,7 +48,15 @@ export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) =
navigationApi.expandRightPanel();
galleryPanel.expand();
flushSync(() => {
dispatch(boardIdSelected({ boardId: imageDTO.board_id ?? 'none', selectedImageName: imageDTO.image_name }));
dispatch(
boardIdSelected({
boardId: imageDTO.board_id ?? 'none',
select: {
selection: [{ type: 'image', id: imageDTO.image_name }],
galleryView: IMAGE_CATEGORIES.includes(imageDTO.image_category) ? 'images' : 'assets',
},
})
);
});
}, [dispatch, galleryPanel, imageDTO]);

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