mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-16 15:37:55 -05:00
Compare commits
14 Commits
controlnet
...
maryhipp/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e048ff1746 | ||
|
|
ecbe948f0c | ||
|
|
440a8bdc5f | ||
|
|
1eae96675b | ||
|
|
8b6cbb714e | ||
|
|
5fff9d3245 | ||
|
|
a0f5189d61 | ||
|
|
6afe995dbe | ||
|
|
d8fcf18b6c | ||
|
|
b3dbf5b914 | ||
|
|
d81716f0b6 | ||
|
|
d86617241d | ||
|
|
b47544968e | ||
|
|
4d5604df48 |
@@ -5,9 +5,11 @@ from logging import Logger
|
||||
|
||||
import torch
|
||||
|
||||
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
from invokeai.app.services.board_images.board_images_default import BoardImagesService
|
||||
# from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
# from invokeai.app.services.board_images.board_images_default import BoardImagesService
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.board_resource_records.board_resource_records_sqlite import SqliteBoardResourceRecordStorage
|
||||
from invokeai.app.services.board_resources.board_resources_default import BoardResourcesService
|
||||
from invokeai.app.services.boards.boards_default import BoardService
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite
|
||||
@@ -40,6 +42,8 @@ from invokeai.app.services.shared.sqlite.sqlite_util import init_db
|
||||
from invokeai.app.services.style_preset_images.style_preset_images_disk import StylePresetImageFileStorageDisk
|
||||
from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage
|
||||
from invokeai.app.services.urls.urls_default import LocalUrlService
|
||||
from invokeai.app.services.video_records.video_records_sqlite import SqliteVideoRecordStorage
|
||||
from invokeai.app.services.videos.videos_default import VideoService
|
||||
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
|
||||
from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk
|
||||
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
|
||||
@@ -103,14 +107,18 @@ class ApiDependencies:
|
||||
configuration = config
|
||||
logger = logger
|
||||
|
||||
board_image_records = SqliteBoardImageRecordStorage(db=db)
|
||||
board_images = BoardImagesService()
|
||||
# board_image_records = SqliteBoardImageRecordStorage(db=db)
|
||||
# board_images = BoardImagesService()
|
||||
board_resource_records = SqliteBoardResourceRecordStorage(db=db)
|
||||
board_resources = BoardResourcesService()
|
||||
board_records = SqliteBoardRecordStorage(db=db)
|
||||
boards = BoardService()
|
||||
events = FastAPIEventService(event_handler_id, loop=loop)
|
||||
bulk_download = BulkDownloadService()
|
||||
image_records = SqliteImageRecordStorage(db=db)
|
||||
images = ImageService()
|
||||
video_records = SqliteVideoRecordStorage(db=db)
|
||||
videos = VideoService()
|
||||
invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size)
|
||||
tensors = ObjectSerializerForwardCache(
|
||||
ObjectSerializerDisk[torch.Tensor](
|
||||
@@ -155,8 +163,10 @@ class ApiDependencies:
|
||||
client_state_persistence = ClientStatePersistenceSqlite(db=db)
|
||||
|
||||
services = InvocationServices(
|
||||
board_image_records=board_image_records,
|
||||
board_images=board_images,
|
||||
# board_image_records=board_image_records,
|
||||
# board_images=board_images,
|
||||
board_resource_records=board_resource_records,
|
||||
board_resources=board_resources,
|
||||
board_records=board_records,
|
||||
boards=boards,
|
||||
bulk_download=bulk_download,
|
||||
@@ -177,6 +187,8 @@ class ApiDependencies:
|
||||
session_processor=session_processor,
|
||||
session_queue=session_queue,
|
||||
urls=urls,
|
||||
videos=videos,
|
||||
video_records=video_records,
|
||||
workflow_records=workflow_records,
|
||||
tensors=tensors,
|
||||
conditioning=conditioning,
|
||||
|
||||
93
invokeai/app/api/routers/board_resources.py
Normal file
93
invokeai/app/api/routers/board_resources.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.routing import APIRouter
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.services.board_resources.board_resources_common import ResourceType
|
||||
from invokeai.app.services.resources.resources_common import AddResourcesToBoardResult, RemoveResourcesFromBoardResult
|
||||
|
||||
board_resources_router = APIRouter(prefix="/v1/board_resources", tags=["boards"])
|
||||
|
||||
|
||||
@board_resources_router.post(
|
||||
"/batch",
|
||||
operation_id="add_resources_to_board",
|
||||
responses={
|
||||
201: {"description": "Resources were added to board successfully"},
|
||||
},
|
||||
status_code=201,
|
||||
response_model=AddResourcesToBoardResult, # For now, using same response model
|
||||
)
|
||||
async def add_resources_to_board(
|
||||
board_id: str = Body(description="The id of the board to add to"),
|
||||
resource_ids: list[str] = Body(description="The ids of the resources to add", embed=True),
|
||||
resource_type: ResourceType = Body(description="The type of resources"),
|
||||
) -> AddResourcesToBoardResult:
|
||||
"""Adds a list of resources to a board"""
|
||||
try:
|
||||
added_resources: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for resource_id in resource_ids:
|
||||
try:
|
||||
if resource_type == ResourceType.IMAGE:
|
||||
old_board_id = ApiDependencies.invoker.services.images.get_dto(resource_id).board_id or "none"
|
||||
else:
|
||||
old_board_id = ApiDependencies.invoker.services.videos.get_dto(resource_id).board_id or "none"
|
||||
|
||||
ApiDependencies.invoker.services.board_resources.add_resource_to_board(
|
||||
board_id=board_id,
|
||||
resource_id=resource_id,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
added_resources.add(resource_id)
|
||||
affected_boards.add(board_id)
|
||||
affected_boards.add(old_board_id)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return AddResourcesToBoardResult(
|
||||
added_resources=list(added_resources),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to add resources to board")
|
||||
|
||||
|
||||
@board_resources_router.post(
|
||||
"/batch/delete",
|
||||
operation_id="remove_resources_from_board",
|
||||
responses={
|
||||
201: {"description": "Resources were removed from board successfully"},
|
||||
},
|
||||
status_code=201,
|
||||
response_model=RemoveResourcesFromBoardResult, # For now, using same response model
|
||||
)
|
||||
async def remove_resources_from_board(
|
||||
resource_ids: list[str] = Body(description="The ids of the resources to remove", embed=True),
|
||||
resource_type: ResourceType = Body(description="The type of resources", embed=True),
|
||||
) -> RemoveResourcesFromBoardResult:
|
||||
"""Removes a list of resources from their board, if they had one"""
|
||||
try:
|
||||
removed_resources: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for resource_id in resource_ids:
|
||||
try:
|
||||
if resource_type == ResourceType.IMAGE:
|
||||
old_board_id = ApiDependencies.invoker.services.images.get_dto(resource_id).board_id or "none"
|
||||
else:
|
||||
# For videos, we'll need to implement this once video service exists
|
||||
old_board_id = ApiDependencies.invoker.services.videos.get_dto(resource_id).board_id or "none"
|
||||
|
||||
ApiDependencies.invoker.services.board_resources.remove_resource_from_board(
|
||||
resource_id=resource_id, resource_type=resource_type
|
||||
)
|
||||
removed_resources.add(resource_id)
|
||||
affected_boards.add("none")
|
||||
affected_boards.add(old_board_id)
|
||||
except Exception:
|
||||
pass
|
||||
return RemoveResourcesFromBoardResult(
|
||||
removed_resources=list(removed_resources),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to remove resources from board")
|
||||
@@ -8,6 +8,7 @@ from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
|
||||
from invokeai.app.services.boards.boards_common import BoardDTO
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.resources.resources_common import ResourceIdentifier, ResourceType
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
|
||||
@@ -16,10 +17,10 @@ boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
|
||||
|
||||
class DeleteBoardResult(BaseModel):
|
||||
board_id: str = Field(description="The id of the board that was deleted.")
|
||||
deleted_board_images: list[str] = Field(
|
||||
description="The image names of the board-images relationships that were deleted."
|
||||
deleted_board_resources: list[str] = Field(
|
||||
description="The resource ids of the board-resources relationships that were deleted."
|
||||
)
|
||||
deleted_images: list[str] = Field(description="The names of the images that were deleted.")
|
||||
deleted_resources: list[str] = Field(description="The names of the resources that were deleted.")
|
||||
|
||||
|
||||
@boards_router.post(
|
||||
@@ -82,34 +83,34 @@ async def update_board(
|
||||
@boards_router.delete("/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult)
|
||||
async def delete_board(
|
||||
board_id: str = Path(description="The id of board to delete"),
|
||||
include_images: Optional[bool] = Query(description="Permanently delete all images on the board", default=False),
|
||||
include_resources: Optional[bool] = Query(
|
||||
description="Permanently delete all resources on the board", default=False
|
||||
),
|
||||
) -> DeleteBoardResult:
|
||||
"""Deletes a board"""
|
||||
try:
|
||||
if include_images is True:
|
||||
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
if include_resources is True:
|
||||
deleted_resources = ApiDependencies.invoker.services.board_resources.get_all_board_resource_ids_for_board(
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
return DeleteBoardResult(
|
||||
board_id=board_id,
|
||||
deleted_board_images=[],
|
||||
deleted_images=deleted_images,
|
||||
deleted_board_resources=[],
|
||||
deleted_resources=deleted_resources,
|
||||
)
|
||||
else:
|
||||
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
deleted_board_resources = (
|
||||
ApiDependencies.invoker.services.board_resources.get_all_board_resource_ids_for_board(
|
||||
board_id=board_id,
|
||||
)
|
||||
)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
return DeleteBoardResult(
|
||||
board_id=board_id,
|
||||
deleted_board_images=deleted_board_images,
|
||||
deleted_images=[],
|
||||
deleted_board_resources=deleted_board_resources,
|
||||
deleted_resources=[],
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete board")
|
||||
@@ -141,20 +142,22 @@ async def list_boards(
|
||||
|
||||
|
||||
@boards_router.get(
|
||||
"/{board_id}/image_names",
|
||||
operation_id="list_all_board_image_names",
|
||||
response_model=list[str],
|
||||
"/{board_id}/resource_ids",
|
||||
operation_id="list_all_board_resource_ids",
|
||||
response_model=list[ResourceIdentifier],
|
||||
)
|
||||
async def list_all_board_image_names(
|
||||
async def list_all_board_resource_ids(
|
||||
board_id: str = Path(description="The id of the board or 'none' for uncategorized images"),
|
||||
resource_type: ResourceType = Query(default=ResourceType.IMAGE, description="The type of resource to include."),
|
||||
categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: bool | None = Query(default=None, description="Whether to list intermediate images."),
|
||||
) -> list[str]:
|
||||
) -> list[ResourceIdentifier]:
|
||||
"""Gets a list of images for a board"""
|
||||
|
||||
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
resources = ApiDependencies.invoker.services.board_resources.get_all_board_resource_ids_for_board(
|
||||
board_id,
|
||||
resource_type,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
return image_names
|
||||
return resources
|
||||
|
||||
@@ -19,13 +19,9 @@ from invokeai.app.services.image_records.image_records_common import (
|
||||
ResourceOrigin,
|
||||
)
|
||||
from invokeai.app.services.images.images_common import (
|
||||
DeleteImagesResult,
|
||||
ImageDTO,
|
||||
ImageUrlsDTO,
|
||||
StarredImagesResult,
|
||||
UnstarredImagesResult,
|
||||
)
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.util.controlnet_utils import heuristic_resize_fast
|
||||
from invokeai.backend.image_util.util import np_to_pil, pil_to_np
|
||||
@@ -160,31 +156,6 @@ async def create_image_upload_entry(
|
||||
raise HTTPException(status_code=501, detail="Not implemented")
|
||||
|
||||
|
||||
@images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult)
|
||||
async def delete_image(
|
||||
image_name: str = Path(description="The name of the image to delete"),
|
||||
) -> DeleteImagesResult:
|
||||
"""Deletes an image"""
|
||||
|
||||
deleted_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
|
||||
try:
|
||||
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
|
||||
board_id = image_dto.board_id or "none"
|
||||
ApiDependencies.invoker.services.images.delete(image_name)
|
||||
deleted_images.add(image_name)
|
||||
affected_boards.add(board_id)
|
||||
except Exception:
|
||||
# TODO: Does this need any exception handling at all?
|
||||
pass
|
||||
|
||||
return DeleteImagesResult(
|
||||
deleted_images=list(deleted_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
|
||||
|
||||
@images_router.delete("/intermediates", operation_id="clear_intermediates")
|
||||
async def clear_intermediates() -> int:
|
||||
"""Clears all intermediates"""
|
||||
@@ -367,136 +338,6 @@ async def get_image_urls(
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
@images_router.get(
|
||||
"/",
|
||||
operation_id="list_image_dtos",
|
||||
response_model=OffsetPaginatedResults[ImageDTO],
|
||||
)
|
||||
async def list_image_dtos(
|
||||
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
|
||||
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
|
||||
board_id: Optional[str] = Query(
|
||||
default=None,
|
||||
description="The board id to filter by. Use 'none' to find images without a board.",
|
||||
),
|
||||
offset: int = Query(default=0, description="The page offset"),
|
||||
limit: int = Query(default=10, description="The number of images 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 images first"),
|
||||
search_term: Optional[str] = Query(default=None, description="The term to search for"),
|
||||
) -> OffsetPaginatedResults[ImageDTO]:
|
||||
"""Gets a list of image DTOs"""
|
||||
|
||||
image_dtos = ApiDependencies.invoker.services.images.get_many(
|
||||
offset, limit, starred_first, order_dir, image_origin, categories, is_intermediate, board_id, search_term
|
||||
)
|
||||
|
||||
return image_dtos
|
||||
|
||||
|
||||
@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult)
|
||||
async def delete_images_from_list(
|
||||
image_names: list[str] = Body(description="The list of names of images to delete", embed=True),
|
||||
) -> DeleteImagesResult:
|
||||
try:
|
||||
deleted_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
|
||||
board_id = image_dto.board_id or "none"
|
||||
ApiDependencies.invoker.services.images.delete(image_name)
|
||||
deleted_images.add(image_name)
|
||||
affected_boards.add(board_id)
|
||||
except Exception:
|
||||
pass
|
||||
return DeleteImagesResult(
|
||||
deleted_images=list(deleted_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete images")
|
||||
|
||||
|
||||
@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult)
|
||||
async def delete_uncategorized_images() -> DeleteImagesResult:
|
||||
"""Deletes all images that are uncategorized"""
|
||||
|
||||
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id="none", categories=None, is_intermediate=None
|
||||
)
|
||||
|
||||
try:
|
||||
deleted_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
ApiDependencies.invoker.services.images.delete(image_name)
|
||||
deleted_images.add(image_name)
|
||||
affected_boards.add("none")
|
||||
except Exception:
|
||||
pass
|
||||
return DeleteImagesResult(
|
||||
deleted_images=list(deleted_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete images")
|
||||
|
||||
|
||||
class ImagesUpdatedFromListResult(BaseModel):
|
||||
updated_image_names: list[str] = Field(description="The image names that were updated")
|
||||
|
||||
|
||||
@images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult)
|
||||
async def star_images_in_list(
|
||||
image_names: list[str] = Body(description="The list of names of images to star", embed=True),
|
||||
) -> StarredImagesResult:
|
||||
try:
|
||||
starred_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
updated_image_dto = ApiDependencies.invoker.services.images.update(
|
||||
image_name, changes=ImageRecordChanges(starred=True)
|
||||
)
|
||||
starred_images.add(image_name)
|
||||
affected_boards.add(updated_image_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
return StarredImagesResult(
|
||||
starred_images=list(starred_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to star images")
|
||||
|
||||
|
||||
@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult)
|
||||
async def unstar_images_in_list(
|
||||
image_names: list[str] = Body(description="The list of names of images to unstar", embed=True),
|
||||
) -> UnstarredImagesResult:
|
||||
try:
|
||||
unstarred_images: set[str] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for image_name in image_names:
|
||||
try:
|
||||
updated_image_dto = ApiDependencies.invoker.services.images.update(
|
||||
image_name, changes=ImageRecordChanges(starred=False)
|
||||
)
|
||||
unstarred_images.add(image_name)
|
||||
affected_boards.add(updated_image_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
return UnstarredImagesResult(
|
||||
unstarred_images=list(unstarred_images),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to unstar images")
|
||||
|
||||
|
||||
class ImagesDownloaded(BaseModel):
|
||||
response: Optional[str] = Field(
|
||||
default=None, description="The message to display to the user when images begin downloading"
|
||||
|
||||
159
invokeai/app/api/routers/resources.py
Normal file
159
invokeai/app/api/routers/resources.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageRecordChanges,
|
||||
)
|
||||
from invokeai.app.services.resources.resources_common import (
|
||||
DeleteResourcesResult,
|
||||
ResourceIdentifier,
|
||||
ResourceType,
|
||||
StarredResourcesResult,
|
||||
UnstarredResourcesResult,
|
||||
)
|
||||
from invokeai.app.services.video_records.video_records_common import VideoRecordChanges
|
||||
|
||||
# routes that act on both images and videos, possibly together
|
||||
resources_router = APIRouter(prefix="/v1/resources", tags=["resources"])
|
||||
|
||||
|
||||
@resources_router.post("/delete", operation_id="delete_resources_from_list", response_model=DeleteResourcesResult)
|
||||
async def delete_resources_from_list(
|
||||
resources: list[ResourceIdentifier] = Body(description="The list of resources to delete", embed=True),
|
||||
) -> DeleteResourcesResult:
|
||||
try:
|
||||
deleted_resources: set[ResourceIdentifier] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for resource in resources:
|
||||
if resource.resource_type == ResourceType.IMAGE:
|
||||
try:
|
||||
image_dto = ApiDependencies.invoker.services.images.get_dto(resource.resource_id)
|
||||
board_id = image_dto.board_id or "none"
|
||||
ApiDependencies.invoker.services.images.delete(resource.resource_id)
|
||||
deleted_resources.add(resource)
|
||||
affected_boards.add(board_id)
|
||||
except Exception:
|
||||
pass
|
||||
elif resource.resource_type == ResourceType.VIDEO:
|
||||
try:
|
||||
video_dto = ApiDependencies.invoker.services.videos.get_dto(resource.resource_id)
|
||||
board_id = video_dto.board_id or "none"
|
||||
ApiDependencies.invoker.services.videos.delete(resource.resource_id)
|
||||
deleted_resources.add(resource)
|
||||
affected_boards.add(board_id)
|
||||
except Exception:
|
||||
pass
|
||||
return DeleteResourcesResult(
|
||||
deleted_resources=list(deleted_resources),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete images")
|
||||
|
||||
|
||||
@resources_router.delete(
|
||||
"/uncategorized", operation_id="delete_uncategorized_resources", response_model=DeleteResourcesResult
|
||||
)
|
||||
async def delete_uncategorized_resources() -> DeleteResourcesResult:
|
||||
"""Deletes all resources that are uncategorized"""
|
||||
|
||||
resources = ApiDependencies.invoker.services.board_resources.get_all_board_resource_ids_for_board(board_id="none")
|
||||
|
||||
try:
|
||||
deleted_resources: set[ResourceIdentifier] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for resource in resources:
|
||||
if resource.resource_type == ResourceType.IMAGE:
|
||||
try:
|
||||
ApiDependencies.invoker.services.images.delete(resource.resource_id)
|
||||
deleted_resources.add(resource)
|
||||
affected_boards.add("none")
|
||||
except Exception:
|
||||
pass
|
||||
elif resource.resource_type == ResourceType.VIDEO:
|
||||
try:
|
||||
ApiDependencies.invoker.services.videos.delete(resource.resource_id)
|
||||
deleted_resources.add(resource)
|
||||
affected_boards.add("none")
|
||||
except Exception:
|
||||
pass
|
||||
return DeleteResourcesResult(
|
||||
deleted_resources=list(deleted_resources),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete images")
|
||||
|
||||
|
||||
class ResourcesUpdatedFromListResult(BaseModel):
|
||||
updated_resource: list[ResourceIdentifier] = Field(description="The resource ids that were updated")
|
||||
|
||||
|
||||
@resources_router.post("/star", operation_id="star_resources_in_list", response_model=StarredResourcesResult)
|
||||
async def star_resources_in_list(
|
||||
resources: list[ResourceIdentifier] = Body(description="The list of resources to star", embed=True),
|
||||
) -> StarredResourcesResult:
|
||||
try:
|
||||
starred_resources: set[ResourceIdentifier] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for resource in resources:
|
||||
if resource.resource_type == ResourceType.IMAGE:
|
||||
try:
|
||||
updated_resource_dto = ApiDependencies.invoker.services.images.update(
|
||||
resource.resource_id, changes=ImageRecordChanges(starred=True)
|
||||
)
|
||||
starred_resources.add(resource)
|
||||
affected_boards.add(updated_resource_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
elif resource.resource_type == ResourceType.VIDEO:
|
||||
try:
|
||||
updated_resource_dto = ApiDependencies.invoker.services.videos.update(
|
||||
resource.resource_id, changes=VideoRecordChanges(starred=True)
|
||||
)
|
||||
starred_resources.add(resource)
|
||||
affected_boards.add(updated_resource_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
return StarredResourcesResult(
|
||||
starred_resources=list(starred_resources),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to star images")
|
||||
|
||||
|
||||
@resources_router.post("/unstar", operation_id="unstar_resources_in_list", response_model=UnstarredResourcesResult)
|
||||
async def unstar_resources_in_list(
|
||||
resources: list[ResourceIdentifier] = Body(description="The list of resources to unstar", embed=True),
|
||||
) -> UnstarredResourcesResult:
|
||||
try:
|
||||
unstarred_resources: set[ResourceIdentifier] = set()
|
||||
affected_boards: set[str] = set()
|
||||
for resource in resources:
|
||||
if resource.resource_type == ResourceType.IMAGE:
|
||||
try:
|
||||
updated_resource_dto = ApiDependencies.invoker.services.images.update(
|
||||
resource.resource_id, changes=ImageRecordChanges(starred=False)
|
||||
)
|
||||
unstarred_resources.add(resource)
|
||||
affected_boards.add(updated_resource_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
elif resource.resource_type == ResourceType.VIDEO:
|
||||
try:
|
||||
updated_resource_dto = ApiDependencies.invoker.services.videos.update(
|
||||
resource.resource_id, changes=VideoRecordChanges(starred=False)
|
||||
)
|
||||
unstarred_resources.add(resource)
|
||||
affected_boards.add(updated_resource_dto.board_id or "none")
|
||||
except Exception:
|
||||
pass
|
||||
return UnstarredResourcesResult(
|
||||
unstarred_resources=list(unstarred_resources),
|
||||
affected_boards=list(affected_boards),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to unstar images")
|
||||
116
invokeai/app/api/routers/videos.py
Normal file
116
invokeai/app/api/routers/videos.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Body, Path, Query, Response
|
||||
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.video_records.video_records_common import (
|
||||
VideoNamesResult,
|
||||
VideoRecordChanges,
|
||||
)
|
||||
from invokeai.app.services.videos.videos_common import (
|
||||
VideoDTO,
|
||||
)
|
||||
|
||||
videos_router = APIRouter(prefix="/v1/videos", tags=["videos"])
|
||||
|
||||
|
||||
# videos are immutable; set a high max-age
|
||||
VIDEO_MAX_AGE = 31536000
|
||||
|
||||
|
||||
@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 NotImplementedError("Not implemented")
|
||||
|
||||
|
||||
@videos_router.get(
|
||||
"/",
|
||||
operation_id="list_video_dtos",
|
||||
response_model=OffsetPaginatedResults[VideoDTO],
|
||||
)
|
||||
async def list_video_dtos(
|
||||
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]:
|
||||
"""Gets a list of video DTOs"""
|
||||
|
||||
raise NotImplementedError("Not implemented")
|
||||
|
||||
|
||||
@videos_router.get("/ids", operation_id="get_video_ids")
|
||||
async def get_video_ids(
|
||||
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"),
|
||||
) -> VideoNamesResult:
|
||||
"""Gets ordered list of video names with metadata for optimistic updates"""
|
||||
|
||||
raise NotImplementedError("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 NotImplementedError("Not implemented")
|
||||
|
||||
|
||||
@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 NotImplementedError("Not implemented")
|
||||
|
||||
|
||||
@videos_router.get(
|
||||
"/i/{video_id}/thumbnail",
|
||||
operation_id="get_video_thumbnail",
|
||||
response_class=Response,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Return the video thumbnail",
|
||||
"content": {"image/webp": {}},
|
||||
},
|
||||
404: {"description": "Video not found"},
|
||||
},
|
||||
)
|
||||
async def get_video_thumbnail(
|
||||
video_id: str = Path(description="The id of video to get thumbnail for"),
|
||||
) -> Response:
|
||||
"""Gets a video thumbnail file"""
|
||||
|
||||
raise NotImplementedError("Not implemented")
|
||||
@@ -17,16 +17,19 @@ from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles
|
||||
from invokeai.app.api.routers import (
|
||||
app_info,
|
||||
board_images,
|
||||
# board_images,
|
||||
board_resources,
|
||||
boards,
|
||||
client_state,
|
||||
download_queue,
|
||||
images,
|
||||
model_manager,
|
||||
model_relationships,
|
||||
resources,
|
||||
session_queue,
|
||||
style_presets,
|
||||
utilities,
|
||||
videos,
|
||||
workflows,
|
||||
)
|
||||
from invokeai.app.api.sockets import SocketIO
|
||||
@@ -125,8 +128,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(resources.resources_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_resources.board_resources_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")
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.resources.resources_common import ResourceIdentifier, ResourceType
|
||||
|
||||
|
||||
class BoardResourceRecordStorageBase(ABC):
|
||||
"""Abstract base class for the one-to-many board-resource relationship record storage."""
|
||||
|
||||
@abstractmethod
|
||||
def add_resource_to_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
"""Adds a resource to a board."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_resource_from_board(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
"""Removes a resource from a board."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all_board_resource_ids_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_type: Optional[ResourceType] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
) -> list[ResourceIdentifier]:
|
||||
"""Gets all board resources for a board, as a list of resource IDs."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_board_for_resource(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> Optional[str]:
|
||||
"""Gets a resource's board id, if it has one."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_resource_count_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_type: Optional[ResourceType] = None,
|
||||
) -> int:
|
||||
"""Gets the number of resources for a board."""
|
||||
pass
|
||||
@@ -0,0 +1,153 @@
|
||||
import sqlite3
|
||||
from typing import Optional, cast
|
||||
|
||||
from invokeai.app.services.board_resource_records.board_resource_records_base import BoardResourceRecordStorageBase
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.resources.resources_common import ResourceIdentifier, ResourceType
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
|
||||
|
||||
class SqliteBoardResourceRecordStorage(BoardResourceRecordStorageBase):
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._db = db
|
||||
|
||||
def add_resource_to_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
with self._db.transaction() as cursor:
|
||||
if resource_type == ResourceType.IMAGE:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO board_images (board_id, image_name)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (image_name) DO UPDATE SET board_id = ?;
|
||||
""",
|
||||
(board_id, resource_id, board_id),
|
||||
)
|
||||
elif resource_type == ResourceType.VIDEO:
|
||||
raise NotImplementedError("Video resource type is not supported in OSS")
|
||||
|
||||
def remove_resource_from_board(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
with self._db.transaction() as cursor:
|
||||
if resource_type == ResourceType.IMAGE:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM board_images
|
||||
WHERE image_name = ?;
|
||||
""",
|
||||
(resource_id,),
|
||||
)
|
||||
elif resource_type == ResourceType.VIDEO:
|
||||
raise NotImplementedError("Video resource type is not supported in OSS")
|
||||
|
||||
def get_all_board_resource_ids_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_type: Optional[ResourceType] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
) -> list[ResourceIdentifier]:
|
||||
image_name_results = []
|
||||
|
||||
with self._db.transaction() as cursor:
|
||||
if resource_type == ResourceType.IMAGE or resource_type is None:
|
||||
params: list[str | bool] = []
|
||||
|
||||
# Base query is a join between images and board_images
|
||||
stmt = """
|
||||
SELECT images.image_name
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
# Handle board_id filter
|
||||
if board_id == "none":
|
||||
stmt += """--sql
|
||||
AND board_images.board_id IS NULL
|
||||
"""
|
||||
else:
|
||||
stmt += """--sql
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
params.append(board_id)
|
||||
|
||||
# Add the category filter
|
||||
if categories is not None:
|
||||
# Convert the enum values to unique list of strings
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
# Create the correct length of placeholders
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
stmt += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
|
||||
# Unpack the included categories into the query params
|
||||
for c in category_strings:
|
||||
params.append(c)
|
||||
|
||||
# Add the is_intermediate filter
|
||||
if is_intermediate is not None:
|
||||
stmt += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
params.append(is_intermediate)
|
||||
|
||||
# Put a ring on it
|
||||
stmt += ";"
|
||||
|
||||
cursor.execute(stmt, params)
|
||||
|
||||
image_name_results = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
|
||||
if resource_type == ResourceType.VIDEO or resource_type is None:
|
||||
# this is not actually a valid code path for OSS, just demonstrating that it could be
|
||||
raise NotImplementedError("Video resource type is not supported in OSS")
|
||||
|
||||
return [
|
||||
ResourceIdentifier(resource_id=image_name, resource_type=ResourceType.IMAGE)
|
||||
for image_name in image_name_results
|
||||
]
|
||||
|
||||
def get_board_for_resource(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> Optional[str]:
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT board_id
|
||||
FROM board_images
|
||||
WHERE image_name = ?;
|
||||
""",
|
||||
(resource_id,),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result is None:
|
||||
return None
|
||||
return cast(str, result[0])
|
||||
|
||||
def get_resource_count_for_board(self, board_id: str, resource_type: Optional[ResourceType] = None) -> int:
|
||||
with self._db.transaction() as cursor:
|
||||
# only images are supported in OSS
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM board_images
|
||||
INNER JOIN images ON board_images.image_name = images.image_name
|
||||
WHERE images.is_intermediate = FALSE
|
||||
AND board_images.board_id = ?;
|
||||
""",
|
||||
(board_id,),
|
||||
)
|
||||
count = cast(int, cursor.fetchone()[0])
|
||||
return count
|
||||
0
invokeai/app/services/board_resources/__init__.py
Normal file
0
invokeai/app/services/board_resources/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.resources.resources_common import ResourceIdentifier, ResourceType
|
||||
|
||||
|
||||
class BoardResourcesServiceABC(ABC):
|
||||
"""High-level service for board-resource relationship management."""
|
||||
|
||||
@abstractmethod
|
||||
def add_resource_to_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
"""Adds a resource (image or video) to a board."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_resource_from_board(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
"""Removes a resource (image or video) from a board."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all_board_resource_ids_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_type: Optional[ResourceType] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
) -> list[ResourceIdentifier]:
|
||||
"""Gets all board resources for a board, as a list of resource IDs."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_board_for_resource(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> Optional[str]:
|
||||
"""Gets a resource's board id, if it has one."""
|
||||
pass
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import Field
|
||||
|
||||
from invokeai.app.services.resources.resources_common import ResourceType
|
||||
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
|
||||
|
||||
|
||||
class BoardResource(BaseModelExcludeNull):
|
||||
"""Represents a resource (image or video) associated with a board."""
|
||||
|
||||
board_id: str = Field(description="The id of the board")
|
||||
resource_id: str = Field(description="The id of the resource (image_name or video_id)")
|
||||
resource_type: ResourceType = Field(description="The type of resource")
|
||||
@@ -0,0 +1,49 @@
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.board_resources.board_resources_base import BoardResourcesServiceABC
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.resources.resources_common import ResourceIdentifier, ResourceType
|
||||
|
||||
|
||||
class BoardResourcesService(BoardResourcesServiceABC):
|
||||
__invoker: Invoker
|
||||
|
||||
def start(self, invoker: Invoker) -> None:
|
||||
self.__invoker = invoker
|
||||
|
||||
def add_resource_to_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
self.__invoker.services.board_resource_records.add_resource_to_board(board_id, resource_id, resource_type)
|
||||
|
||||
def remove_resource_from_board(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> None:
|
||||
self.__invoker.services.board_resource_records.remove_resource_from_board(resource_id, resource_type)
|
||||
|
||||
def get_all_board_resource_ids_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
resource_type: Optional[ResourceType] = None,
|
||||
categories: Optional[list[ImageCategory]] = None,
|
||||
is_intermediate: Optional[bool] = None,
|
||||
) -> list[ResourceIdentifier]:
|
||||
return self.__invoker.services.board_resource_records.get_all_board_resource_ids_for_board(
|
||||
board_id,
|
||||
resource_type,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
|
||||
def get_board_for_resource(
|
||||
self,
|
||||
resource_id: str,
|
||||
resource_type: ResourceType,
|
||||
) -> Optional[str]:
|
||||
return self.__invoker.services.board_resource_records.get_board_for_resource(resource_id, resource_type)
|
||||
@@ -12,12 +12,17 @@ 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."""
|
||||
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, 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,
|
||||
video_count=video_count,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from invokeai.app.services.board_records.board_records_common import BoardChange
|
||||
from invokeai.app.services.boards.boards_base import BoardServiceABC
|
||||
from invokeai.app.services.boards.boards_common import BoardDTO, board_record_to_dto
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.resources.resources_common import ResourceType
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
|
||||
@@ -17,7 +18,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)
|
||||
|
||||
def get_dto(self, board_id: str) -> BoardDTO:
|
||||
board_record = self.__invoker.services.board_records.get(board_id)
|
||||
@@ -26,8 +27,13 @@ class BoardService(BoardServiceABC):
|
||||
cover_image_name = cover_image.image_name
|
||||
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)
|
||||
image_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
board_id, ResourceType.IMAGE
|
||||
)
|
||||
video_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
board_id, ResourceType.VIDEO
|
||||
)
|
||||
return board_record_to_dto(board_record, cover_image_name, image_count, video_count)
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -41,8 +47,13 @@ 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)
|
||||
image_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
board_id, ResourceType.IMAGE
|
||||
)
|
||||
video_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
board_id, ResourceType.VIDEO
|
||||
)
|
||||
return board_record_to_dto(board_record, cover_image_name, image_count, video_count)
|
||||
|
||||
def delete(self, board_id: str) -> None:
|
||||
self.__invoker.services.board_records.delete(board_id)
|
||||
@@ -58,7 +69,7 @@ class BoardService(BoardServiceABC):
|
||||
board_records = self.__invoker.services.board_records.get_many(
|
||||
order_by, direction, offset, limit, include_archived
|
||||
)
|
||||
board_dtos = []
|
||||
board_dtos: list[BoardDTO] = []
|
||||
for r in board_records.items:
|
||||
cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id)
|
||||
if cover_image:
|
||||
@@ -66,8 +77,13 @@ class BoardService(BoardServiceABC):
|
||||
else:
|
||||
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))
|
||||
image_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
r.board_id, ResourceType.IMAGE
|
||||
)
|
||||
video_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
r.board_id, ResourceType.VIDEO
|
||||
)
|
||||
board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, video_count))
|
||||
|
||||
return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos))
|
||||
|
||||
@@ -75,7 +91,7 @@ class BoardService(BoardServiceABC):
|
||||
self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False
|
||||
) -> list[BoardDTO]:
|
||||
board_records = self.__invoker.services.board_records.get_all(order_by, direction, include_archived)
|
||||
board_dtos = []
|
||||
board_dtos: list[BoardDTO] = []
|
||||
for r in board_records:
|
||||
cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id)
|
||||
if cover_image:
|
||||
@@ -83,7 +99,12 @@ class BoardService(BoardServiceABC):
|
||||
else:
|
||||
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))
|
||||
image_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
r.board_id, ResourceType.IMAGE
|
||||
)
|
||||
video_count = self.__invoker.services.board_resource_records.get_resource_count_for_board(
|
||||
r.board_id, ResourceType.VIDEO
|
||||
)
|
||||
board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, video_count))
|
||||
|
||||
return board_dtos
|
||||
|
||||
@@ -14,6 +14,7 @@ from invokeai.app.services.bulk_download.bulk_download_common import (
|
||||
from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException
|
||||
from invokeai.app.services.images.images_common import ImageDTO
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.resources.resources_common import ResourceType
|
||||
from invokeai.app.util.misc import uuid_string
|
||||
|
||||
|
||||
@@ -63,12 +64,11 @@ class BulkDownloadService(BulkDownloadBase):
|
||||
return [self._invoker.services.images.get_dto(image_name) for image_name in image_names]
|
||||
|
||||
def _board_handler(self, board_id: str) -> list[ImageDTO]:
|
||||
image_names = self._invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
resource_ids = self._invoker.services.board_resource_records.get_all_board_resource_ids_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
resource_type=ResourceType.IMAGE,
|
||||
)
|
||||
return self._image_handler(image_names)
|
||||
return self._image_handler([resource_id.resource_id for resource_id in resource_ids])
|
||||
|
||||
def generate_item_id(self, board_id: Optional[str]) -> str:
|
||||
return uuid_string() if board_id is None else self._get_clean_board_name(board_id) + "_" + uuid_string()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageRecord
|
||||
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
|
||||
@@ -17,14 +17,24 @@ class ImageUrlsDTO(BaseModelExcludeNull):
|
||||
"""The URL of the image's thumbnail."""
|
||||
|
||||
|
||||
def make_type_required(s: dict[str, Any]):
|
||||
if "required" in s:
|
||||
s["required"].append("type")
|
||||
else:
|
||||
s["required"] = ["type"]
|
||||
|
||||
|
||||
class ImageDTO(ImageRecord, ImageUrlsDTO):
|
||||
"""Deserialized image record, enriched for the frontend."""
|
||||
|
||||
type: Literal["image"] = Field(default="image")
|
||||
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."""
|
||||
|
||||
model_config = ConfigDict(json_schema_extra=make_type_required)
|
||||
|
||||
|
||||
def image_record_to_dto(
|
||||
image_record: ImageRecord,
|
||||
@@ -39,27 +49,3 @@ def image_record_to_dto(
|
||||
thumbnail_url=thumbnail_url,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
|
||||
class ResultWithAffectedBoards(BaseModel):
|
||||
affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation")
|
||||
|
||||
|
||||
class DeleteImagesResult(ResultWithAffectedBoards):
|
||||
deleted_images: list[str] = Field(description="The names of the images that were deleted")
|
||||
|
||||
|
||||
class StarredImagesResult(ResultWithAffectedBoards):
|
||||
starred_images: list[str] = Field(description="The names of the images that were starred")
|
||||
|
||||
|
||||
class UnstarredImagesResult(ResultWithAffectedBoards):
|
||||
unstarred_images: list[str] = Field(description="The names of the images that were unstarred")
|
||||
|
||||
|
||||
class AddImagesToBoardResult(ResultWithAffectedBoards):
|
||||
added_images: list[str] = Field(description="The image names that were added to the board")
|
||||
|
||||
|
||||
class RemoveImagesFromBoardResult(ResultWithAffectedBoards):
|
||||
removed_images: list[str] = Field(description="The image names that were removed from their board")
|
||||
|
||||
@@ -23,6 +23,7 @@ from invokeai.app.services.image_records.image_records_common import (
|
||||
from invokeai.app.services.images.images_base import ImageServiceABC
|
||||
from invokeai.app.services.images.images_common import ImageDTO, image_record_to_dto
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.resources.resources_common import ResourceType
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
|
||||
@@ -75,8 +76,8 @@ class ImageService(ImageServiceABC):
|
||||
)
|
||||
if board_id is not None:
|
||||
try:
|
||||
self.__invoker.services.board_image_records.add_image_to_board(
|
||||
board_id=board_id, image_name=image_name
|
||||
self.__invoker.services.board_resource_records.add_resource_to_board(
|
||||
board_id=board_id, resource_id=image_name, resource_type=ResourceType.IMAGE
|
||||
)
|
||||
except Exception as e:
|
||||
self.__invoker.services.logger.warning(f"Failed to add image to board {board_id}: {str(e)}")
|
||||
@@ -142,7 +143,9 @@ class ImageService(ImageServiceABC):
|
||||
image_record=image_record,
|
||||
image_url=self.__invoker.services.urls.get_image_url(image_name),
|
||||
thumbnail_url=self.__invoker.services.urls.get_image_url(image_name, True),
|
||||
board_id=self.__invoker.services.board_image_records.get_board_for_image(image_name),
|
||||
board_id=self.__invoker.services.board_resource_records.get_board_for_resource(
|
||||
image_name, ResourceType.IMAGE
|
||||
),
|
||||
)
|
||||
|
||||
return image_dto
|
||||
@@ -234,7 +237,9 @@ class ImageService(ImageServiceABC):
|
||||
image_record=r,
|
||||
image_url=self.__invoker.services.urls.get_image_url(r.image_name),
|
||||
thumbnail_url=self.__invoker.services.urls.get_image_url(r.image_name, True),
|
||||
board_id=self.__invoker.services.board_image_records.get_board_for_image(r.image_name),
|
||||
board_id=self.__invoker.services.board_resource_records.get_board_for_resource(
|
||||
r.image_name, ResourceType.IMAGE
|
||||
),
|
||||
)
|
||||
for r in results.items
|
||||
]
|
||||
@@ -266,10 +271,9 @@ class ImageService(ImageServiceABC):
|
||||
|
||||
def delete_images_on_board(self, board_id: str):
|
||||
try:
|
||||
image_names = self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
image_names = self.__invoker.services.board_resource_records.get_all_board_resource_ids_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
resource_type=ResourceType.IMAGE,
|
||||
)
|
||||
for image_name in image_names:
|
||||
self.__invoker.services.image_files.delete(image_name)
|
||||
|
||||
@@ -12,9 +12,11 @@ if TYPE_CHECKING:
|
||||
|
||||
import torch
|
||||
|
||||
from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
|
||||
from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
|
||||
# from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
|
||||
# from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
|
||||
from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase
|
||||
from invokeai.app.services.board_resource_records.board_resource_records_base import BoardResourceRecordStorageBase
|
||||
from invokeai.app.services.board_resources.board_resources_base import BoardResourcesServiceABC
|
||||
from invokeai.app.services.boards.boards_base import BoardServiceABC
|
||||
from invokeai.app.services.bulk_download.bulk_download_base import BulkDownloadBase
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
|
||||
@@ -36,6 +38,8 @@ if TYPE_CHECKING:
|
||||
from invokeai.app.services.session_processor.session_processor_base import SessionProcessorBase
|
||||
from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase
|
||||
from invokeai.app.services.urls.urls_base import UrlServiceBase
|
||||
from invokeai.app.services.video_records.video_records_base import VideoRecordStorageBase
|
||||
from invokeai.app.services.videos.videos_base import VideoServiceABC
|
||||
from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
|
||||
from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase
|
||||
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
|
||||
@@ -46,8 +50,10 @@ class InvocationServices:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
board_images: "BoardImagesServiceABC",
|
||||
board_image_records: "BoardImageRecordStorageBase",
|
||||
# board_images: "BoardImagesServiceABC",
|
||||
# board_image_records: "BoardImageRecordStorageBase",
|
||||
board_resources: "BoardResourcesServiceABC",
|
||||
board_resource_records: "BoardResourceRecordStorageBase",
|
||||
boards: "BoardServiceABC",
|
||||
board_records: "BoardRecordStorageBase",
|
||||
bulk_download: "BulkDownloadBase",
|
||||
@@ -68,6 +74,8 @@ class InvocationServices:
|
||||
invocation_cache: "InvocationCacheBase",
|
||||
names: "NameServiceBase",
|
||||
urls: "UrlServiceBase",
|
||||
videos: "VideoServiceABC",
|
||||
video_records: "VideoRecordStorageBase",
|
||||
workflow_records: "WorkflowRecordsStorageBase",
|
||||
tensors: "ObjectSerializerBase[torch.Tensor]",
|
||||
conditioning: "ObjectSerializerBase[ConditioningFieldData]",
|
||||
@@ -76,8 +84,10 @@ class InvocationServices:
|
||||
workflow_thumbnails: "WorkflowThumbnailServiceBase",
|
||||
client_state_persistence: "ClientStatePersistenceABC",
|
||||
):
|
||||
self.board_images = board_images
|
||||
self.board_image_records = board_image_records
|
||||
# self.board_images = board_images
|
||||
# self.board_image_records = board_image_records
|
||||
self.board_resources = board_resources
|
||||
self.board_resource_records = board_resource_records
|
||||
self.boards = boards
|
||||
self.board_records = board_records
|
||||
self.bulk_download = bulk_download
|
||||
@@ -98,6 +108,8 @@ class InvocationServices:
|
||||
self.invocation_cache = invocation_cache
|
||||
self.names = names
|
||||
self.urls = urls
|
||||
self.videos = videos
|
||||
self.video_records = video_records
|
||||
self.workflow_records = workflow_records
|
||||
self.tensors = tensors
|
||||
self.conditioning = conditioning
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from enum import Enum, EnumMeta
|
||||
|
||||
|
||||
class ResourceType(str, Enum, metaclass=EnumMeta):
|
||||
"""Enum for resource types."""
|
||||
|
||||
IMAGE = "image"
|
||||
LATENT = "latent"
|
||||
39
invokeai/app/services/resources/resources_common.py
Normal file
39
invokeai/app/services/resources/resources_common.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ResourceType(str, Enum):
|
||||
"""The type of resource that can be associated with a board."""
|
||||
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
class ResourceIdentifier(BaseModel):
|
||||
resource_id: str = Field(description="The id of the resource to delete")
|
||||
resource_type: ResourceType = Field(description="The type of the resource to delete")
|
||||
|
||||
|
||||
class ResultWithAffectedBoards(BaseModel):
|
||||
affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation")
|
||||
|
||||
|
||||
class DeleteResourcesResult(ResultWithAffectedBoards):
|
||||
deleted_resources: list[ResourceIdentifier] = Field(description="The ids of the resources that were deleted")
|
||||
|
||||
|
||||
class StarredResourcesResult(ResultWithAffectedBoards):
|
||||
starred_resources: list[ResourceIdentifier] = Field(description="The resources that were starred")
|
||||
|
||||
|
||||
class UnstarredResourcesResult(ResultWithAffectedBoards):
|
||||
unstarred_resources: list[ResourceIdentifier] = Field(description="The resources that were unstarred")
|
||||
|
||||
|
||||
class AddResourcesToBoardResult(ResultWithAffectedBoards):
|
||||
added_resources: list[ResourceIdentifier] = Field(description="The resources that were added to the board")
|
||||
|
||||
|
||||
class RemoveResourcesFromBoardResult(ResultWithAffectedBoards):
|
||||
removed_resources: list[ResourceIdentifier] = Field(description="The resources that were removed from their board")
|
||||
@@ -16,6 +16,7 @@ from invokeai.app.services.image_records.image_records_common import ImageCatego
|
||||
from invokeai.app.services.images.images_common import ImageDTO
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.model_records.model_records_base import UnknownModelException
|
||||
from invokeai.app.services.resources.resources_common import ResourceType
|
||||
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.util.step_callback import diffusion_step_callback
|
||||
@@ -112,7 +113,7 @@ class BoardsInterface(InvocationContextInterface):
|
||||
board_id: The ID of the board to add the image to.
|
||||
image_name: The name of the image to add to the board.
|
||||
"""
|
||||
return self._services.board_images.add_image_to_board(board_id, image_name)
|
||||
return self._services.board_resources.add_resource_to_board(board_id, image_name, ResourceType.IMAGE)
|
||||
|
||||
def get_all_image_names_for_board(self, board_id: str) -> list[str]:
|
||||
"""Gets all image names for a board.
|
||||
@@ -123,11 +124,11 @@ class BoardsInterface(InvocationContextInterface):
|
||||
Returns:
|
||||
A list of all image names for the board.
|
||||
"""
|
||||
return self._services.board_images.get_all_board_image_names_for_board(
|
||||
resource_ids = self._services.board_resources.get_all_board_resource_ids_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
resource_type=ResourceType.IMAGE,
|
||||
)
|
||||
return [resource_id.resource_id for resource_id in resource_ids]
|
||||
|
||||
|
||||
class LoggerInterface(InvocationContextInterface):
|
||||
|
||||
0
invokeai/app/services/video_records/__init__.py
Normal file
0
invokeai/app/services/video_records/__init__.py
Normal file
105
invokeai/app/services/video_records/video_records_base.py
Normal file
105
invokeai/app/services/video_records/video_records_base.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.invocations.fields import MetadataField
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.services.video_records.video_records_common import (
|
||||
VideoNamesResult,
|
||||
VideoRecord,
|
||||
VideoRecordChanges,
|
||||
)
|
||||
|
||||
|
||||
class VideoRecordStorageBase(ABC):
|
||||
"""Low-level service responsible for interfacing with the video record store."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, video_id: str) -> VideoRecord:
|
||||
"""Gets a video record."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_metadata(self, video_id: str) -> Optional[MetadataField]:
|
||||
"""Gets a video's metadata."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(
|
||||
self,
|
||||
video_id: str,
|
||||
changes: VideoRecordChanges,
|
||||
) -> None:
|
||||
"""Updates a video record."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_many(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[VideoRecord]:
|
||||
"""Gets a page of video records."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, video_id: str) -> None:
|
||||
"""Deletes a video record."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_many(self, video_ids: list[str]) -> None:
|
||||
"""Deletes many video records."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save(
|
||||
self,
|
||||
video_id: str,
|
||||
width: int,
|
||||
height: int,
|
||||
duration: Optional[float] = None,
|
||||
frame_rate: Optional[float] = None,
|
||||
node_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
metadata: Optional[str] = None,
|
||||
workflow: Optional[str] = None,
|
||||
graph: Optional[str] = None,
|
||||
) -> VideoRecord:
|
||||
"""Saves a video record."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_workflow(self, video_id: str) -> Optional[str]:
|
||||
"""Gets a video's workflow."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_graph(self, video_id: str) -> Optional[str]:
|
||||
"""Gets a video's graph."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_video_names(
|
||||
self,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> VideoNamesResult:
|
||||
"""Gets video names with metadata for optimistic updates."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_intermediates_count(self) -> int:
|
||||
"""Gets the count of intermediate videos."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_intermediates(self) -> int:
|
||||
"""Deletes all intermediate videos and returns the count of deleted videos."""
|
||||
pass
|
||||
52
invokeai/app/services/video_records/video_records_common.py
Normal file
52
invokeai/app/services/video_records/video_records_common.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import datetime
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
|
||||
|
||||
VIDEO_DTO_COLS = ", ".join(
|
||||
[
|
||||
"videos." + c
|
||||
for c in [
|
||||
"id",
|
||||
"width",
|
||||
"height",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class VideoRecord(BaseModelExcludeNull):
|
||||
"""Deserialized video record without metadata."""
|
||||
|
||||
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."""
|
||||
height: int = Field(description="The height of the video in px.")
|
||||
"""The actual height of the video in px."""
|
||||
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."""
|
||||
|
||||
|
||||
class VideoRecordChanges(BaseModelExcludeNull):
|
||||
"""
|
||||
A set of changes to apply to a video record.
|
||||
|
||||
Only limited changes are allowed:
|
||||
- `starred` - Whether the video is starred.
|
||||
"""
|
||||
|
||||
starred: Optional[bool] = Field(default=None, description="Whether the video is starred.")
|
||||
"""The video's new `starred` state."""
|
||||
|
||||
|
||||
class VideoNamesResult(BaseModel):
|
||||
"""Result of fetching video names."""
|
||||
|
||||
video_ids: list[str] = Field(description="The video IDs")
|
||||
86
invokeai/app/services/video_records/video_records_sqlite.py
Normal file
86
invokeai/app/services/video_records/video_records_sqlite.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.invocations.fields import MetadataField
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
from invokeai.app.services.video_records.video_records_base import VideoRecordStorageBase
|
||||
from invokeai.app.services.video_records.video_records_common import (
|
||||
VideoNamesResult,
|
||||
VideoRecord,
|
||||
VideoRecordChanges,
|
||||
)
|
||||
|
||||
|
||||
class SqliteVideoRecordStorage(VideoRecordStorageBase):
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._db = db
|
||||
|
||||
def get(self, video_id: str) -> VideoRecord:
|
||||
# For now, this is a placeholder that raises NotImplementedError
|
||||
# In a real implementation, this would query the videos table
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def get_metadata(self, video_id: str) -> Optional[MetadataField]:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def update(
|
||||
self,
|
||||
video_id: str,
|
||||
changes: VideoRecordChanges,
|
||||
) -> None:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def get_many(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[VideoRecord]:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def delete(self, video_id: str) -> None:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def delete_many(self, video_ids: list[str]) -> None:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def save(
|
||||
self,
|
||||
video_id: str,
|
||||
width: int,
|
||||
height: int,
|
||||
duration: Optional[float] = None,
|
||||
frame_rate: Optional[float] = None,
|
||||
node_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
metadata: Optional[str] = None,
|
||||
workflow: Optional[str] = None,
|
||||
graph: Optional[str] = None,
|
||||
) -> VideoRecord:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def get_workflow(self, video_id: str) -> Optional[str]:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def get_graph(self, video_id: str) -> Optional[str]:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def get_video_names(
|
||||
self,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> VideoNamesResult:
|
||||
raise NotImplementedError("Video record storage not yet implemented")
|
||||
|
||||
def get_intermediates_count(self) -> int:
|
||||
return 0 # Placeholder implementation
|
||||
|
||||
def delete_intermediates(self) -> int:
|
||||
return 0 # Placeholder implementation
|
||||
0
invokeai/app/services/videos/__init__.py
Normal file
0
invokeai/app/services/videos/__init__.py
Normal file
151
invokeai/app/services/videos/videos_base.py
Normal file
151
invokeai/app/services/videos/videos_base.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Optional
|
||||
|
||||
from invokeai.app.invocations.fields import MetadataField
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.services.video_records.video_records_common import (
|
||||
VideoNamesResult,
|
||||
VideoRecord,
|
||||
VideoRecordChanges,
|
||||
)
|
||||
from invokeai.app.services.videos.videos_common import VideoDTO
|
||||
|
||||
|
||||
class VideoServiceABC(ABC):
|
||||
"""High-level service for video management."""
|
||||
|
||||
_on_changed_callbacks: list[Callable[[VideoDTO], None]]
|
||||
_on_deleted_callbacks: list[Callable[[str], None]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._on_changed_callbacks = []
|
||||
self._on_deleted_callbacks = []
|
||||
|
||||
def on_changed(self, on_changed: Callable[[VideoDTO], None]) -> None:
|
||||
"""Register a callback for when a video is changed."""
|
||||
self._on_changed_callbacks.append(on_changed)
|
||||
|
||||
def on_deleted(self, on_deleted: Callable[[str], None]) -> None:
|
||||
"""Register a callback for when a video is deleted."""
|
||||
self._on_deleted_callbacks.append(on_deleted)
|
||||
|
||||
def _on_changed(self, item: VideoDTO) -> None:
|
||||
for callback in self._on_changed_callbacks:
|
||||
callback(item)
|
||||
|
||||
def _on_deleted(self, item_id: str) -> None:
|
||||
for callback in self._on_deleted_callbacks:
|
||||
callback(item_id)
|
||||
|
||||
@abstractmethod
|
||||
def create(
|
||||
self,
|
||||
video_id: str,
|
||||
width: int,
|
||||
height: int,
|
||||
duration: Optional[float] = None,
|
||||
frame_rate: Optional[float] = None,
|
||||
node_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
board_id: Optional[str] = None,
|
||||
is_intermediate: Optional[bool] = False,
|
||||
metadata: Optional[str] = None,
|
||||
workflow: Optional[str] = None,
|
||||
graph: Optional[str] = None,
|
||||
) -> VideoDTO:
|
||||
"""Creates a video record and returns its DTO."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(
|
||||
self,
|
||||
video_id: str,
|
||||
changes: VideoRecordChanges,
|
||||
) -> VideoDTO:
|
||||
"""Updates a video record and returns its DTO."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_record(self, video_id: str) -> VideoRecord:
|
||||
"""Gets a video record."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_dto(self, video_id: str) -> VideoDTO:
|
||||
"""Gets a video DTO."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_metadata(self, video_id: str) -> Optional[MetadataField]:
|
||||
"""Gets a video's metadata."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_workflow(self, video_id: str) -> Optional[str]:
|
||||
"""Gets a video's workflow."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_graph(self, video_id: str) -> Optional[str]:
|
||||
"""Gets a video's graph."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_path(self, video_id: str, thumbnail: bool = False) -> str:
|
||||
"""Gets a video's path on disk."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate_path(self, path: str) -> bool:
|
||||
"""Validates a video path."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_url(self, video_id: str, thumbnail: bool = False) -> str:
|
||||
"""Gets a video's URL."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_many(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[VideoDTO]:
|
||||
"""Gets a page of video DTOs."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, video_id: str):
|
||||
"""Deletes a video."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_intermediates(self) -> int:
|
||||
"""Deletes all intermediate videos and returns the count."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_intermediates_count(self) -> int:
|
||||
"""Gets the count of intermediate videos."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_videos_on_board(self, board_id: str):
|
||||
"""Deletes all videos on a board."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_video_names(
|
||||
self,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> VideoNamesResult:
|
||||
"""Gets video names with metadata for optimistic updates."""
|
||||
pass
|
||||
38
invokeai/app/services/videos/videos_common.py
Normal file
38
invokeai/app/services/videos/videos_common.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from invokeai.app.services.video_records.video_records_common import VideoRecord
|
||||
|
||||
|
||||
def make_type_required(s: dict[str, Any]):
|
||||
if "required" in s:
|
||||
s["required"].append("type")
|
||||
else:
|
||||
s["required"] = ["type"]
|
||||
|
||||
|
||||
class VideoDTO(BaseModel):
|
||||
"""Deserialized video record, enriched for the frontend."""
|
||||
|
||||
type: Literal["video"] = Field(default="video")
|
||||
video_id: str = Field(description="The id of the board the video belongs to, if one exists.")
|
||||
"""The id of the board the video belongs to, if one exists."""
|
||||
width: int = Field(description="The width of the video.")
|
||||
height: int = Field(description="The height of the video.")
|
||||
board_id: Optional[str] = Field(
|
||||
default=None, description="The id of the board the video belongs to, if one exists."
|
||||
)
|
||||
|
||||
model_config = ConfigDict(json_schema_extra=make_type_required)
|
||||
|
||||
|
||||
def video_record_to_dto(
|
||||
video_record: VideoRecord,
|
||||
board_id: Optional[str],
|
||||
) -> VideoDTO:
|
||||
"""Converts a VideoRecord to a VideoDTO."""
|
||||
return VideoDTO(
|
||||
**video_record.model_dump(),
|
||||
board_id=board_id,
|
||||
)
|
||||
103
invokeai/app/services/videos/videos_default.py
Normal file
103
invokeai/app/services/videos/videos_default.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.invocations.fields import MetadataField
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.services.video_records.video_records_common import (
|
||||
VideoNamesResult,
|
||||
VideoRecord,
|
||||
VideoRecordChanges,
|
||||
)
|
||||
from invokeai.app.services.videos.videos_base import VideoServiceABC
|
||||
from invokeai.app.services.videos.videos_common import VideoDTO
|
||||
|
||||
|
||||
class VideoService(VideoServiceABC):
|
||||
__invoker: Invoker
|
||||
|
||||
def start(self, invoker: Invoker) -> None:
|
||||
self.__invoker = invoker
|
||||
|
||||
def create(
|
||||
self,
|
||||
video_id: str,
|
||||
width: int,
|
||||
height: int,
|
||||
duration: Optional[float] = None,
|
||||
frame_rate: Optional[float] = None,
|
||||
node_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
board_id: Optional[str] = None,
|
||||
is_intermediate: Optional[bool] = False,
|
||||
metadata: Optional[str] = None,
|
||||
workflow: Optional[str] = None,
|
||||
graph: Optional[str] = None,
|
||||
) -> VideoDTO:
|
||||
# For now, this is a placeholder implementation
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def update(
|
||||
self,
|
||||
video_id: str,
|
||||
changes: VideoRecordChanges,
|
||||
) -> VideoDTO:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_record(self, video_id: str) -> VideoRecord:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_dto(self, video_id: str) -> VideoDTO:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_metadata(self, video_id: str) -> Optional[MetadataField]:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_workflow(self, video_id: str) -> Optional[str]:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_graph(self, video_id: str) -> Optional[str]:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_path(self, video_id: str, thumbnail: bool = False) -> str:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def validate_path(self, path: str) -> bool:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_url(self, video_id: str, thumbnail: bool = False) -> str:
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_many(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[VideoDTO]:
|
||||
# Return empty results for now
|
||||
return OffsetPaginatedResults(items=[], offset=offset, limit=limit, total=0, has_more=False)
|
||||
|
||||
def delete(self, video_id: str):
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def delete_intermediates(self) -> int:
|
||||
return 0 # Placeholder
|
||||
|
||||
def get_intermediates_count(self) -> int:
|
||||
return 0 # Placeholder
|
||||
|
||||
def delete_videos_on_board(self, board_id: str):
|
||||
raise NotImplementedError("Video service not yet implemented")
|
||||
|
||||
def get_video_names(
|
||||
self,
|
||||
starred_first: bool = True,
|
||||
order_dir: SQLiteDirection = SQLiteDirection.Descending,
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> VideoNamesResult:
|
||||
# Return empty results for now
|
||||
return VideoNamesResult(video_ids=[])
|
||||
@@ -1243,7 +1243,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)",
|
||||
@@ -2554,13 +2553,15 @@
|
||||
"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"
|
||||
"canvas": "Canvas",
|
||||
"video": "Video"
|
||||
},
|
||||
"launchpad": {
|
||||
"workflowsTitle": "Go deep with Workflows.",
|
||||
|
||||
@@ -11,7 +11,7 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.deleteBoardAndImages.matchFulfilled,
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
const { deleted_images } = action.payload;
|
||||
const { deleted_resources } = action.payload;
|
||||
|
||||
// Remove all deleted images from the UI
|
||||
|
||||
@@ -23,8 +23,8 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS
|
||||
const upscale = selectUpscaleSlice(state);
|
||||
const refImages = selectRefImagesSlice(state);
|
||||
|
||||
deleted_images.forEach((image_name) => {
|
||||
const imageUsage = getImageUsage(nodes, canvas, upscale, refImages, image_name);
|
||||
deleted_resources.forEach((resource_id) => {
|
||||
const imageUsage = getImageUsage(nodes, canvas, upscale, refImages, resource_id);
|
||||
|
||||
if (imageUsage.isNodesImage && !wasNodeEditorReset) {
|
||||
dispatch(nodeEditorReset());
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
|
||||
const log = logger('gallery');
|
||||
|
||||
export const addImageAddedToBoardFulfilledListener = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
|
||||
effect: (action) => {
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
log.debug({ board_id, image_name }, 'Image added to board');
|
||||
},
|
||||
});
|
||||
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
|
||||
effect: (action) => {
|
||||
const { board_id, image_name } = action.meta.arg.originalArgs;
|
||||
log.debug({ board_id, image_name }, 'Problem adding image to board');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
|
||||
const log = logger('gallery');
|
||||
|
||||
export const addImageRemovedFromBoardFulfilledListener = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled,
|
||||
effect: (action) => {
|
||||
const imageDTO = action.meta.arg.originalArgs;
|
||||
log.debug({ imageDTO }, 'Image removed from board');
|
||||
},
|
||||
});
|
||||
|
||||
startAppListening({
|
||||
matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected,
|
||||
effect: (action) => {
|
||||
const imageDTO = action.meta.arg.originalArgs;
|
||||
log.debug({ imageDTO }, 'Problem removing image from board');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -11,8 +11,6 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l
|
||||
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
|
||||
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
|
||||
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
|
||||
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
|
||||
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
|
||||
import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
|
||||
import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
|
||||
import { addSetDefaultSettingsListener } from 'app/store/middleware/listenerMiddleware/listeners/setDefaultSettings';
|
||||
@@ -267,8 +265,6 @@ addSocketConnectedEventListener(startAppListening);
|
||||
addBulkDownloadListeners(startAppListening);
|
||||
|
||||
// Boards
|
||||
addImageAddedToBoardFulfilledListener(startAppListening);
|
||||
addImageRemovedFromBoardFulfilledListener(startAppListening);
|
||||
addBoardIdSelectedListener(startAppListening);
|
||||
addArchivedOrDeletedBoardListener(startAppListening);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
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 { useAddResourcesToBoardMutation, useRemoveResourcesFromBoardMutation } from 'services/api/endpoints/resources';
|
||||
|
||||
const selectImagesToChange = createSelector(
|
||||
selectChangeBoardModalSlice,
|
||||
@@ -30,8 +30,8 @@ const ChangeBoardModal = () => {
|
||||
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
|
||||
const isModalOpen = useAppSelector(selectIsModalOpen);
|
||||
const imagesToChange = useAppSelector(selectImagesToChange);
|
||||
const [addImagesToBoard] = useAddImagesToBoardMutation();
|
||||
const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation();
|
||||
const [addImagesToBoard] = useAddResourcesToBoardMutation();
|
||||
const [removeImagesFromBoard] = useRemoveResourcesFromBoardMutation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
@@ -56,11 +56,12 @@ const ChangeBoardModal = () => {
|
||||
}
|
||||
|
||||
if (selectedBoard === 'none') {
|
||||
removeImagesFromBoard({ image_names: imagesToChange });
|
||||
removeImagesFromBoard({ resource_ids: imagesToChange, resource_type: 'image' });
|
||||
} else {
|
||||
addImagesToBoard({
|
||||
image_names: imagesToChange,
|
||||
resource_ids: imagesToChange,
|
||||
board_id: selectedBoard,
|
||||
resource_type: 'image',
|
||||
});
|
||||
}
|
||||
setSelectedBoard(null);
|
||||
|
||||
@@ -113,6 +113,7 @@ export const createMockQueueItem = (overrides: PartialDeep<S['SessionQueueItem']
|
||||
) as S['SessionQueueItem'];
|
||||
|
||||
export const createMockImageDTO = (overrides: Partial<ImageDTO> = {}): ImageDTO => ({
|
||||
type: 'image',
|
||||
image_name: 'test-image.png',
|
||||
image_url: 'http://test.com/test-image.png',
|
||||
thumbnail_url: 'http://test.com/test-image-thumb.png',
|
||||
|
||||
@@ -23,6 +23,7 @@ import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemS
|
||||
import { atom } from 'nanostores';
|
||||
import { useMemo } from 'react';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { resourcesApi } from 'services/api/endpoints/resources';
|
||||
import type { Param0 } from 'tsafe';
|
||||
|
||||
// Implements an awaitable modal dialog for deleting images
|
||||
@@ -82,11 +83,11 @@ const handleDeletions = async (image_names: string[], store: AppStore) => {
|
||||
const state = getState();
|
||||
const { data } = imagesApi.endpoints.getImageNames.select(selectGetImageNamesQueryArgs(state))(state);
|
||||
const index = data?.image_names.findIndex((name) => name === image_names[0]);
|
||||
const { deleted_images } = await dispatch(
|
||||
imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })
|
||||
const { deleted_resources } = await dispatch(
|
||||
resourcesApi.endpoints.deleteResources.initiate({ resources: image_names.map((name) => ({ resource_id: name, resource_type: 'image' })) }, { track: false })
|
||||
).unwrap();
|
||||
|
||||
const newImageNames = data?.image_names.filter((name) => !deleted_images.includes(name)) || [];
|
||||
const newImageNames = data?.image_names.filter((name) => !deleted_resources.map(r => r.resource_id).includes(name)) || [];
|
||||
const newSelectedImage = newImageNames[index ?? 0] || null;
|
||||
|
||||
if (intersection(state.gallery.selection, image_names).length > 0) {
|
||||
|
||||
@@ -26,12 +26,12 @@ import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';
|
||||
import { atom } from 'nanostores';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListAllImageNamesForBoardQuery } from 'services/api/endpoints/boards';
|
||||
import { useListAllResourceIdsForBoardQuery } from 'services/api/endpoints/boards';
|
||||
import {
|
||||
useDeleteBoardAndImagesMutation,
|
||||
useDeleteBoardMutation,
|
||||
useDeleteUncategorizedImagesMutation,
|
||||
} from 'services/api/endpoints/images';
|
||||
import { useDeleteUncategorizedResourcesMutation } from 'services/api/endpoints/resources';
|
||||
import type { BoardDTO } from 'services/api/types';
|
||||
|
||||
export const $boardToDelete = atom<BoardDTO | 'none' | null>(null);
|
||||
@@ -43,12 +43,13 @@ const DeleteBoardModal = () => {
|
||||
|
||||
const boardId = useMemo(() => (boardToDelete === 'none' ? 'none' : boardToDelete?.board_id), [boardToDelete]);
|
||||
|
||||
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } = useListAllImageNamesForBoardQuery(
|
||||
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } = useListAllResourceIdsForBoardQuery(
|
||||
boardId
|
||||
? {
|
||||
board_id: boardId,
|
||||
categories: undefined,
|
||||
is_intermediate: undefined,
|
||||
resource_type: 'image',
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
@@ -82,8 +83,8 @@ const DeleteBoardModal = () => {
|
||||
|
||||
const [deleteBoardAndImages, { isLoading: isDeleteBoardAndImagesLoading }] = useDeleteBoardAndImagesMutation();
|
||||
|
||||
const [deleteUncategorizedImages, { isLoading: isDeleteUncategorizedImagesLoading }] =
|
||||
useDeleteUncategorizedImagesMutation();
|
||||
const [deleteUncategorizedResources, { isLoading: isDeleteUncategorizedResourcesLoading }] =
|
||||
useDeleteUncategorizedResourcesMutation();
|
||||
|
||||
const imageUsageSummary = useAppSelector(selectImageUsageSummary);
|
||||
|
||||
@@ -103,13 +104,13 @@ const DeleteBoardModal = () => {
|
||||
$boardToDelete.set(null);
|
||||
}, [boardToDelete, deleteBoardAndImages]);
|
||||
|
||||
const handleDeleteUncategorizedImages = useCallback(() => {
|
||||
const handleDeleteUncategorizedResources = useCallback(() => {
|
||||
if (!boardToDelete || boardToDelete !== 'none') {
|
||||
return;
|
||||
}
|
||||
deleteUncategorizedImages();
|
||||
deleteUncategorizedResources();
|
||||
$boardToDelete.set(null);
|
||||
}, [boardToDelete, deleteUncategorizedImages]);
|
||||
}, [boardToDelete, deleteUncategorizedResources]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
$boardToDelete.set(null);
|
||||
@@ -122,8 +123,8 @@ const DeleteBoardModal = () => {
|
||||
isDeleteBoardAndImagesLoading ||
|
||||
isDeleteBoardOnlyLoading ||
|
||||
isFetchingBoardNames ||
|
||||
isDeleteUncategorizedImagesLoading,
|
||||
[isDeleteBoardAndImagesLoading, isDeleteBoardOnlyLoading, isFetchingBoardNames, isDeleteUncategorizedImagesLoading]
|
||||
isDeleteUncategorizedResourcesLoading,
|
||||
[isDeleteBoardAndImagesLoading, isDeleteBoardOnlyLoading, isFetchingBoardNames, isDeleteUncategorizedResourcesLoading]
|
||||
);
|
||||
|
||||
if (!boardToDelete) {
|
||||
@@ -177,10 +178,10 @@ const DeleteBoardModal = () => {
|
||||
</Button>
|
||||
)}
|
||||
{boardToDelete === 'none' && (
|
||||
<Button colorScheme="error" isLoading={isLoading} onClick={handleDeleteUncategorizedImages}>
|
||||
<Button colorScheme="error" isLoading={isLoading} onClick={handleDeleteUncategorizedResources}>
|
||||
{t('boards.deleteAllUncategorizedImages')}
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</Flex>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -5,26 +5,26 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
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 { useStarResourcesMutation, useUnstarResourcesMutation } from 'services/api/endpoints/resources';
|
||||
|
||||
export const ImageMenuItemStarUnstar = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const customStarUi = useStore($customStarUI);
|
||||
const [starImages] = useStarImagesMutation();
|
||||
const [unstarImages] = useUnstarImagesMutation();
|
||||
const [starResources] = useStarResourcesMutation();
|
||||
const [unstarResources] = useUnstarResourcesMutation();
|
||||
|
||||
const starImage = useCallback(() => {
|
||||
if (imageDTO) {
|
||||
starImages({ image_names: [imageDTO.image_name] });
|
||||
starResources({ resources: [{ resource_id: imageDTO.image_name, resource_type: "image" }] });
|
||||
}
|
||||
}, [starImages, imageDTO]);
|
||||
}, [starResources, imageDTO]);
|
||||
|
||||
const unstarImage = useCallback(() => {
|
||||
if (imageDTO) {
|
||||
unstarImages({ image_names: [imageDTO.image_name] });
|
||||
unstarResources({ resources: [{ resource_id: imageDTO.image_name, resource_type: "image" }] });
|
||||
}
|
||||
}, [unstarImages, imageDTO]);
|
||||
}, [unstarResources, imageDTO]);
|
||||
|
||||
if (imageDTO.starred) {
|
||||
return (
|
||||
|
||||
@@ -10,9 +10,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { PiDownloadSimpleBold, PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi';
|
||||
import {
|
||||
useBulkDownloadImagesMutation,
|
||||
useStarImagesMutation,
|
||||
useUnstarImagesMutation,
|
||||
} from 'services/api/endpoints/images';
|
||||
import { useStarResourcesMutation, useUnstarResourcesMutation } from 'services/api/endpoints/resources';
|
||||
|
||||
const MultipleSelectionMenuItems = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,8 +22,8 @@ const MultipleSelectionMenuItems = () => {
|
||||
|
||||
const isBulkDownloadEnabled = useFeatureStatus('bulkDownload');
|
||||
|
||||
const [starImages] = useStarImagesMutation();
|
||||
const [unstarImages] = useUnstarImagesMutation();
|
||||
const [starResources] = useStarResourcesMutation();
|
||||
const [unstarResources] = useUnstarResourcesMutation();
|
||||
const [bulkDownload] = useBulkDownloadImagesMutation();
|
||||
|
||||
const handleChangeBoard = useCallback(() => {
|
||||
@@ -37,12 +36,12 @@ const MultipleSelectionMenuItems = () => {
|
||||
}, [deleteImageModal, selection]);
|
||||
|
||||
const handleStarSelection = useCallback(() => {
|
||||
starImages({ image_names: selection });
|
||||
}, [starImages, selection]);
|
||||
starResources({ resources: selection.map(image => ({ resource_id: image, resource_type: "image" })) });
|
||||
}, [starResources, selection]);
|
||||
|
||||
const handleUnstarSelection = useCallback(() => {
|
||||
unstarImages({ image_names: selection });
|
||||
}, [unstarImages, selection]);
|
||||
unstarResources({ resources: selection.map(image => ({ resource_id: image, resource_type: "image" })) });
|
||||
}, [unstarResources, selection]);
|
||||
|
||||
const handleBulkDownload = useCallback(() => {
|
||||
bulkDownload({ image_names: selection });
|
||||
|
||||
@@ -14,15 +14,14 @@ import { ImageMenuItemOpenInViewer } from 'features/gallery/components/ImageCont
|
||||
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 { ImageMenuItemUseAsPromptTemplate } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsPromptTemplate';
|
||||
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;
|
||||
@@ -59,4 +58,4 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) =
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SingleSelectionMenuItems);
|
||||
export default SingleSelectionMenuItems;
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreview
|
||||
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
|
||||
import { firefoxDndFix } from 'features/dnd/util';
|
||||
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
|
||||
import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons';
|
||||
import {
|
||||
selectGetImageNamesQueryArgs,
|
||||
selectSelectedBoardId,
|
||||
@@ -28,6 +27,8 @@ import { PiImageBold } from 'react-icons/pi';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { GalleryResourceHoverIcons } from './GalleryResourceHoverIcons';
|
||||
|
||||
const galleryImageContainerSX = {
|
||||
containerType: 'inline-size',
|
||||
w: 'full',
|
||||
@@ -262,7 +263,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
maxH="full"
|
||||
borderRadius="base"
|
||||
/>
|
||||
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered} />
|
||||
<GalleryResourceHoverIcons resource={imageDTO} isHovered={isHovered} />
|
||||
</Flex>
|
||||
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}
|
||||
{dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useShiftModifier } from '@invoke-ai/ui-library';
|
||||
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
|
||||
import { DndImageIcon } from 'features/dnd/DndImageIcon';
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
imageDTO: ImageDTO;
|
||||
};
|
||||
|
||||
export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
|
||||
const shift = useShiftModifier();
|
||||
const { t } = useTranslation();
|
||||
const deleteImageModal = useDeleteImageModalApi();
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
deleteImageModal.delete([imageDTO.image_name]);
|
||||
},
|
||||
[deleteImageModal, imageDTO]
|
||||
);
|
||||
|
||||
if (!shift) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndImageIcon
|
||||
onClick={onClick}
|
||||
icon={<PiTrashSimpleFill />}
|
||||
tooltip={t('gallery.deleteImage_one')}
|
||||
position="absolute"
|
||||
bottom={2}
|
||||
insetInlineEnd={2}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryImageDeleteIconButton.displayName = 'GalleryImageDeleteIconButton';
|
||||
@@ -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';
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { DndImageIcon } from 'features/dnd/DndImageIcon';
|
||||
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowsOutBold } from 'react-icons/pi';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
imageDTO: ImageDTO;
|
||||
};
|
||||
|
||||
export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(imageToCompareChanged(null));
|
||||
dispatch(imageSelected(imageDTO.image_name));
|
||||
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
|
||||
}, [dispatch, imageDTO]);
|
||||
|
||||
return (
|
||||
<DndImageIcon
|
||||
onClick={onClick}
|
||||
icon={<PiArrowsOutBold />}
|
||||
tooltip={t('gallery.openInViewer')}
|
||||
position="absolute"
|
||||
insetBlockStart={2}
|
||||
insetInlineStart={2}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryImageOpenInViewerIconButton.displayName = 'GalleryImageOpenInViewerIconButton';
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Text } from '@invoke-ai/ui-library';
|
||||
import { memo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
imageDTO: ImageDTO;
|
||||
};
|
||||
|
||||
export const GalleryImageSizeBadge = memo(({ imageDTO }: Props) => {
|
||||
return (
|
||||
<Text
|
||||
className="gallery-image-size-badge"
|
||||
position="absolute"
|
||||
background="base.900"
|
||||
color="base.50"
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
bottom={1}
|
||||
left={1}
|
||||
opacity={0.7}
|
||||
px={2}
|
||||
lineHeight={1.25}
|
||||
borderTopEndRadius="base"
|
||||
pointerEvents="none"
|
||||
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryImageSizeBadge.displayName = 'GalleryImageSizeBadge';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { GalleryImage } from 'features/gallery/components/ImageGrid/GalleryImage';
|
||||
import { memo } from 'react';
|
||||
import type { ImageDTO, VideoDTO } from 'services/api/types';
|
||||
|
||||
import { GalleryVideo } from './GalleryVideo';
|
||||
|
||||
interface GalleryItemProps {
|
||||
item: ImageDTO | VideoDTO;
|
||||
}
|
||||
|
||||
export const GalleryItem = memo(({ item }: GalleryItemProps) => {
|
||||
if ('image_name' in item) {
|
||||
return <GalleryImage imageDTO={item} />;
|
||||
} else {
|
||||
return <GalleryVideo videoDTO={item} />;
|
||||
}
|
||||
});
|
||||
|
||||
GalleryItem.displayName = 'GalleryItem';
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { getResourceId, isImageResource, isVideoResource } from 'features/gallery/store/resourceTypes';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiTrashSimpleBold } from 'react-icons/pi';
|
||||
import type { ImageDTO, VideoDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
resource: ImageDTO | VideoDTO;
|
||||
isHovered: boolean;
|
||||
};
|
||||
|
||||
export const GalleryResourceDeleteIconButton = memo(({ resource, isHovered }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
// For now, we'll use the existing image delete modal for both images and videos
|
||||
// Later, we can create a resource-specific delete modal
|
||||
if (isImageResource(resource)) {
|
||||
// dispatch(imageToDeleteSelected({ imageName: resource.id, imageUsage: { isCanvasImage: false, isInitialImage: false, isControlLayerImage: false, isNodesImage: false } }));
|
||||
} else if (isVideoResource(resource)) {
|
||||
// For videos, we'll need to implement video deletion
|
||||
// For now, just log that we would delete the video
|
||||
console.log('Would delete video:', getResourceId(resource));
|
||||
}
|
||||
}, [resource]);
|
||||
|
||||
const tooltip = useMemo(() => {
|
||||
if (isImageResource(resource)) {
|
||||
return t('gallery.deleteImage');
|
||||
} else {
|
||||
return t('gallery.deleteVideo');
|
||||
}
|
||||
}, [resource, t]);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip}>
|
||||
<IconButton
|
||||
onClick={onClick}
|
||||
icon={<PiTrashSimpleBold />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={tooltip}
|
||||
colorScheme="error"
|
||||
fontSize={14}
|
||||
position="absolute"
|
||||
top={1}
|
||||
insetInlineEnd={1}
|
||||
visibility={isHovered ? 'visible' : 'hidden'}
|
||||
color="error.300"
|
||||
_hover={{
|
||||
color: 'error.400',
|
||||
bg: 'error.500',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryResourceDeleteIconButton.displayName = 'GalleryResourceDeleteIconButton';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { GalleryResourceDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryResourceDeleteIconButton';
|
||||
import { GalleryResourceOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryResourceOpenInViewerIconButton';
|
||||
import { GalleryResourceSizeBadge } from 'features/gallery/components/ImageGrid/GalleryResourceSizeBadge';
|
||||
import { GalleryResourceStarIconButton } from 'features/gallery/components/ImageGrid/GalleryResourceStarIconButton';
|
||||
import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo } from 'react';
|
||||
import type { ImageDTO, VideoDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
resource: ImageDTO | VideoDTO;
|
||||
isHovered: boolean;
|
||||
};
|
||||
|
||||
export const GalleryResourceHoverIcons = memo(({ resource, isHovered }: Props) => {
|
||||
const shouldShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GalleryResourceDeleteIconButton resource={resource} isHovered={isHovered} />
|
||||
<GalleryResourceStarIconButton resource={resource} isHovered={isHovered} />
|
||||
<GalleryResourceOpenInViewerIconButton resource={resource} isHovered={isHovered} />
|
||||
<GalleryResourceSizeBadge resource={resource} shouldShow={shouldShowImageSizeBadge || isHovered} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryResourceHoverIcons.displayName = 'GalleryResourceHoverIcons';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { imageToCompareChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { isImageResource, } from 'features/gallery/store/resourceTypes';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowsOutBold } from 'react-icons/pi';
|
||||
import type { ImageDTO, VideoDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
resource: ImageDTO | VideoDTO;
|
||||
isHovered: boolean;
|
||||
};
|
||||
|
||||
export const GalleryResourceOpenInViewerIconButton = memo(({ resource, isHovered }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
// Clear any image comparison state and open the viewer
|
||||
dispatch(imageToCompareChanged(null));
|
||||
navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
|
||||
}, [dispatch]);
|
||||
|
||||
const tooltip = useMemo(() => {
|
||||
if (isImageResource(resource)) {
|
||||
return t('gallery.openInViewer');
|
||||
} else {
|
||||
return t('gallery.openVideoInViewer');
|
||||
}
|
||||
}, [resource, t]);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip}>
|
||||
<IconButton
|
||||
onClick={onClick}
|
||||
icon={<PiArrowsOutBold />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={tooltip}
|
||||
colorScheme="base"
|
||||
fontSize={14}
|
||||
position="absolute"
|
||||
bottom={1}
|
||||
insetInlineEnd={1}
|
||||
visibility={isHovered ? 'visible' : 'hidden'}
|
||||
color="base.100"
|
||||
_hover={{
|
||||
color: 'base.50',
|
||||
bg: 'base.500',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryResourceOpenInViewerIconButton.displayName = 'GalleryResourceOpenInViewerIconButton';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Badge } from '@invoke-ai/ui-library';
|
||||
import { isVideoResource } from 'features/gallery/store/resourceTypes';
|
||||
import { memo } from 'react';
|
||||
import type { ImageDTO, VideoDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
resource: ImageDTO | VideoDTO;
|
||||
shouldShow: boolean;
|
||||
};
|
||||
|
||||
export const GalleryResourceSizeBadge = memo(({ resource, shouldShow }: Props) => {
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getBadgeText = () => {
|
||||
if (isVideoResource(resource)) {
|
||||
return `${resource.width}×${resource.height}`;
|
||||
} else {
|
||||
return `${resource.width}×${resource.height}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className="gallery-resource-size-badge"
|
||||
position="absolute"
|
||||
bottom={1}
|
||||
insetInlineStart={1}
|
||||
colorScheme="base"
|
||||
variant="solid"
|
||||
fontSize={10}
|
||||
size="xs"
|
||||
userSelect="none"
|
||||
opacity={0.7}
|
||||
>
|
||||
{getBadgeText()}
|
||||
</Badge>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryResourceSizeBadge.displayName = 'GalleryResourceSizeBadge';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { isImageResource, isVideoResource} from 'features/gallery/store/resourceTypes';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiStarBold, PiStarFill } from 'react-icons/pi';
|
||||
import { useStarResourcesMutation, useUnstarResourcesMutation } from 'services/api/endpoints/resources';
|
||||
import type { ImageDTO, VideoDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
resource: ImageDTO | VideoDTO;
|
||||
isHovered: boolean;
|
||||
};
|
||||
|
||||
export const GalleryResourceStarIconButton = memo(({ resource, isHovered }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [starResources] = useStarResourcesMutation();
|
||||
const [unstarResources] = useUnstarResourcesMutation();
|
||||
|
||||
const toggleStarred = useCallback(() => {
|
||||
if (isImageResource(resource)) {
|
||||
if (resource.starred) {
|
||||
unstarResources({ resources: [{ resource_id: resource.image_name, resource_type: 'image' }] });
|
||||
} else {
|
||||
starResources({ resources: [{ resource_id: resource.image_name, resource_type: 'image' }] });
|
||||
}
|
||||
} else if (isVideoResource(resource)) {
|
||||
// For videos, we'll need to implement video starring
|
||||
// For now, just log that we would star/unstar the video
|
||||
}
|
||||
}, [resource, starResources, unstarResources]);
|
||||
|
||||
const starred = useMemo(() => {
|
||||
if (isImageResource(resource)) {
|
||||
return resource.starred;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, [resource]);
|
||||
|
||||
const tooltip = useMemo(() => {
|
||||
if (starred) {
|
||||
return isImageResource(resource) ? t('gallery.unstarImage') : t('gallery.unstarVideo');
|
||||
} else {
|
||||
return isImageResource(resource) ? t('gallery.starImage') : t('gallery.starVideo');
|
||||
}
|
||||
}, [resource, t, starred]);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip}>
|
||||
<IconButton
|
||||
onClick={toggleStarred}
|
||||
icon={starred ? <PiStarFill /> : <PiStarBold />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={tooltip}
|
||||
colorScheme={starred ? 'invokeYellow' : 'base'}
|
||||
fontSize={14}
|
||||
position="absolute"
|
||||
top={1}
|
||||
insetInlineStart={1}
|
||||
visibility={isHovered || starred ? 'visible' : 'hidden'}
|
||||
color={starred ? 'invokeYellow.300' : 'base.100'}
|
||||
_hover={{
|
||||
color: starred ? 'invokeYellow.400' : 'base.50',
|
||||
bg: starred ? 'invokeYellow.500' : 'base.500',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryResourceStarIconButton.displayName = 'GalleryResourceStarIconButton';
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { FlexProps, SystemStyleObject } 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';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { uniq } from 'es-toolkit';
|
||||
import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage';
|
||||
import { createMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage';
|
||||
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
|
||||
import { createSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
|
||||
import {
|
||||
selectGetImageNamesQueryArgs,
|
||||
} 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, useMemo, useRef, useState } from 'react';
|
||||
import { PiImageBold } from 'react-icons/pi';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { VideoDTO } from 'services/api/types';
|
||||
|
||||
import { GalleryResourceHoverIcons } from './GalleryResourceHoverIcons';
|
||||
|
||||
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;
|
||||
|
||||
interface Props {
|
||||
videoDTO: VideoDTO;
|
||||
}
|
||||
|
||||
const buildOnClick =
|
||||
(imageName: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent<HTMLDivElement>) => {
|
||||
const { shiftKey, ctrlKey, metaKey, altKey } = e;
|
||||
const state = getState();
|
||||
const queryArgs = selectGetImageNamesQueryArgs(state);
|
||||
const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(state).data?.image_names ?? [];
|
||||
|
||||
// If we don't have the image names cached, we can't perform selection operations
|
||||
// This can happen if the user clicks on an image before the names are loaded
|
||||
if (imageNames.length === 0) {
|
||||
// For basic click without modifiers, we can still set selection
|
||||
if (!shiftKey && !ctrlKey && !metaKey && !altKey) {
|
||||
dispatch(selectionChanged([imageName]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = state.gallery.selection;
|
||||
|
||||
if (altKey) {
|
||||
if (state.gallery.imageToCompare === imageName) {
|
||||
dispatch(imageToCompareChanged(null));
|
||||
} else {
|
||||
dispatch(imageToCompareChanged(imageName));
|
||||
}
|
||||
} else if (shiftKey) {
|
||||
const rangeEndImageName = imageName;
|
||||
const lastSelectedImage = selection.at(-1);
|
||||
const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage);
|
||||
const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName);
|
||||
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
|
||||
// We have a valid range!
|
||||
const start = Math.min(lastClickedIndex, currentClickedIndex);
|
||||
const end = Math.max(lastClickedIndex, currentClickedIndex);
|
||||
const imagesToSelect = imageNames.slice(start, end + 1);
|
||||
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect))));
|
||||
}
|
||||
} else if (ctrlKey || metaKey) {
|
||||
if (selection.some((n) => n === imageName) && selection.length > 1) {
|
||||
dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName))));
|
||||
} else {
|
||||
dispatch(selectionChanged(uniq(selection.concat(imageName))));
|
||||
}
|
||||
} else {
|
||||
dispatch(selectionChanged([imageName]));
|
||||
}
|
||||
};
|
||||
|
||||
export const GalleryVideo = memo(({ videoDTO }: Props) => {
|
||||
const store = useAppStore();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPreviewState, setDragPreviewState] = useState<
|
||||
DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null
|
||||
>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const selectIsSelectedForCompare = useMemo(
|
||||
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare === videoDTO.video_id),
|
||||
[videoDTO.video_id]
|
||||
);
|
||||
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
|
||||
const selectIsSelected = useMemo(
|
||||
() => createSelector(selectGallerySlice, (gallery) => gallery.selection.includes(videoDTO.video_id)),
|
||||
[videoDTO.video_id]
|
||||
);
|
||||
const isSelected = useAppSelector(selectIsSelected);
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
ref={ref}
|
||||
sx={galleryImageContainerSX}
|
||||
data-is-dragging={isDragging}
|
||||
data-video-id={videoDTO.video_id}
|
||||
role="button"
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
data-selected={isSelected}
|
||||
data-selected-for-compare={isSelectedForCompare}
|
||||
>
|
||||
<Image
|
||||
pointerEvents="none"
|
||||
src="" // TODO: Add video thumbnail
|
||||
w={videoDTO.width}
|
||||
fallback={<GalleryVideoPlaceholder />}
|
||||
objectFit="contain"
|
||||
maxW="full"
|
||||
maxH="full"
|
||||
borderRadius="base"
|
||||
/>
|
||||
<GalleryResourceHoverIcons resource={videoDTO} isHovered={isHovered} />
|
||||
</Flex>
|
||||
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}
|
||||
{dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryVideo.displayName = 'GalleryVideo';
|
||||
|
||||
export const GalleryVideoPlaceholder = memo((props: FlexProps) => (
|
||||
<Flex w="full" h="full" bg="base.850" borderRadius="base" alignItems="center" justifyContent="center" {...props}>
|
||||
<Icon as={PiImageBold} boxSize={16} color="base.800" />
|
||||
</Flex>
|
||||
));
|
||||
|
||||
GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder';
|
||||
@@ -25,10 +25,12 @@ import type {
|
||||
VirtuosoGridHandle,
|
||||
} from 'react-virtuoso';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { imagesApi, useImageDTO, useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
|
||||
import { imagesApi, useImageDTO, } from 'services/api/endpoints/images';
|
||||
import { useStarResourcesMutation, useUnstarResourcesMutation } from 'services/api/endpoints/resources';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage';
|
||||
import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage';
|
||||
import { GalleryItem } from './ImageGrid/GalleryItem';
|
||||
import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag';
|
||||
import { useGalleryImageNames } from './use-gallery-image-names';
|
||||
|
||||
@@ -62,7 +64,7 @@ const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string
|
||||
return <GalleryImagePlaceholder data-image-name={imageName} />;
|
||||
}
|
||||
|
||||
return <GalleryImage imageDTO={imageDTO} />;
|
||||
return <GalleryItem item={imageDTO} />;
|
||||
});
|
||||
ImageAtPosition.displayName = 'ImageAtPosition';
|
||||
|
||||
@@ -456,8 +458,8 @@ const useStarImageHotkey = () => {
|
||||
const selectionCount = useAppSelector(selectSelectionCount);
|
||||
const isGalleryFocused = useIsRegionFocused('gallery');
|
||||
const imageDTO = useImageDTO(lastSelectedImage);
|
||||
const [starImages] = useStarImagesMutation();
|
||||
const [unstarImages] = useUnstarImagesMutation();
|
||||
const [starResources] = useStarResourcesMutation();
|
||||
const [unstarResources] = useUnstarResourcesMutation();
|
||||
|
||||
const handleStarHotkey = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
@@ -467,11 +469,11 @@ const useStarImageHotkey = () => {
|
||||
return;
|
||||
}
|
||||
if (imageDTO.starred) {
|
||||
unstarImages({ image_names: [imageDTO.image_name] });
|
||||
unstarResources({ resources: [{ resource_id: imageDTO.image_name, resource_type: "image" }] });
|
||||
} else {
|
||||
starImages({ image_names: [imageDTO.image_name] });
|
||||
starResources({ resources: [{ resource_id: imageDTO.image_name, resource_type: "image" }] });
|
||||
}
|
||||
}, [imageDTO, isGalleryFocused, starImages, unstarImages]);
|
||||
}, [imageDTO, isGalleryFocused, starResources, unstarResources]);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'starImage',
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
import { useGetImageNamesQuery } from 'services/api/endpoints/images';
|
||||
|
||||
export const LOADING_SYMBOL = Symbol('LOADING');
|
||||
|
||||
export const useHasImages = () => {
|
||||
const { data: boardList, isLoading: loadingBoards } = useListAllBoardsQuery({ include_archived: true });
|
||||
const { data: uncategorizedImages, isLoading: loadingImages } = useListImagesQuery({
|
||||
const { data: uncategorizedImages, isLoading: loadingImages } = useGetImageNamesQuery({
|
||||
board_id: 'none',
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
is_intermediate: false,
|
||||
});
|
||||
|
||||
@@ -26,7 +24,7 @@ export const useHasImages = () => {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return uncategorizedImages ? uncategorizedImages.total > 0 : true;
|
||||
return uncategorizedImages ? uncategorizedImages.total_count > 0 : true;
|
||||
}, [boardList, uncategorizedImages, loadingBoards, loadingImages]);
|
||||
|
||||
return hasImages;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ImageDTO, VideoDTO } from 'services/api/types';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
// Type guards
|
||||
export function isImageResource(resource: ImageDTO | VideoDTO): resource is ImageDTO {
|
||||
return resource.type === 'image';
|
||||
}
|
||||
|
||||
export function isVideoResource(resource: ImageDTO | VideoDTO): resource is VideoDTO {
|
||||
return resource.type === 'video';
|
||||
}
|
||||
|
||||
export const getResourceId = (resource: ImageDTO | VideoDTO): string => {
|
||||
switch (resource.type) {
|
||||
case 'image':
|
||||
return resource.image_name;
|
||||
case 'video':
|
||||
return resource.video_id;
|
||||
default:
|
||||
assert<Equals<never, typeof resource>>(false);
|
||||
}
|
||||
};
|
||||
@@ -36,7 +36,8 @@ import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSli
|
||||
import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { imageDTOToFile, imagesApi, uploadImage } from 'services/api/endpoints/images';
|
||||
import { imageDTOToFile, uploadImage } from 'services/api/endpoints/images';
|
||||
import { resourcesApi } from 'services/api/endpoints/resources';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
@@ -309,12 +310,12 @@ export const replaceCanvasEntityObjectsWithImage = (arg: {
|
||||
|
||||
export const addImagesToBoard = (arg: { image_names: string[]; boardId: BoardId; dispatch: AppDispatch }) => {
|
||||
const { image_names, boardId, dispatch } = arg;
|
||||
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ image_names, board_id: boardId }, { track: false }));
|
||||
dispatch(resourcesApi.endpoints.addResourcesToBoard.initiate({ resource_ids: image_names, board_id: boardId, resource_type: 'image' }, { track: false }));
|
||||
dispatch(selectionChanged([]));
|
||||
};
|
||||
|
||||
export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: AppDispatch }) => {
|
||||
const { image_names, dispatch } = arg;
|
||||
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false }));
|
||||
dispatch(resourcesApi.endpoints.removeResourcesFromBoard.initiate({ resource_ids: image_names, resource_type: 'image' }, { track: false }));
|
||||
dispatch(selectionChanged([]));
|
||||
};
|
||||
|
||||
@@ -188,3 +188,4 @@ export const zImageOutput = z.object({
|
||||
});
|
||||
export type ImageOutput = z.infer<typeof zImageOutput>;
|
||||
// #endregion
|
||||
|
||||
|
||||
@@ -1771,11 +1771,12 @@ const getImageGeneratorImagesFromBoardValues = async (
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
const req = dispatch(
|
||||
boardsApi.endpoints.listAllImageNamesForBoard.initiate(
|
||||
boardsApi.endpoints.listAllResourceIdsForBoard.initiate(
|
||||
{
|
||||
board_id,
|
||||
categories: category === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
|
||||
is_intermediate: false,
|
||||
resource_type: 'image',
|
||||
},
|
||||
{ subscribe: false }
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { memo } from 'react';
|
||||
export const Prompts = memo(() => {
|
||||
const modelSupportsNegativePrompt = useAppSelector(selectModelSupportsNegativePrompt);
|
||||
const hasNegativePrompt = useAppSelector(selectHasNegativePrompt);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<ParamPositivePrompt />
|
||||
|
||||
@@ -87,3 +87,4 @@ export const selectWithModelsTab = createSelector(selectDidLoad, selectDisabledT
|
||||
export const selectWithQueueTab = createSelector(selectDidLoad, selectDisabledTabs, (didLoad, disabledTabs) =>
|
||||
didLoad ? !disabledTabs.includes('queue') : false
|
||||
);
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import queryString from 'query-string';
|
||||
import type {
|
||||
BoardDTO,
|
||||
CreateBoardArg,
|
||||
GetImageNamesResult,
|
||||
ImageCategory,
|
||||
ListBoardsArgs,
|
||||
OffsetPaginatedResults_ImageDTO_,
|
||||
ResourceType,
|
||||
UpdateBoardArg,
|
||||
} from 'services/api/types';
|
||||
import { getListImagesUrl } from 'services/api/util';
|
||||
@@ -49,13 +50,13 @@ export const boardsApi = api.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
|
||||
listAllImageNamesForBoard: build.query<
|
||||
listAllResourceIdsForBoard: build.query<
|
||||
Array<string>,
|
||||
{ board_id: string | 'none'; categories: ImageCategory[] | undefined; is_intermediate: boolean | undefined }
|
||||
{ board_id: string | 'none'; categories: ImageCategory[] | undefined; is_intermediate: boolean | undefined; resource_type: ResourceType }
|
||||
>({
|
||||
query: ({ board_id, categories, is_intermediate }) => ({
|
||||
query: ({ board_id, categories, is_intermediate, resource_type }) => ({
|
||||
url: buildBoardsUrl(
|
||||
`${board_id}/image_names?${queryString.stringify({ categories, is_intermediate }, { arrayFormat: 'none' })}`
|
||||
`${board_id}/resource_ids?${queryString.stringify({ categories, is_intermediate, resource_type }, { arrayFormat: 'none' })}`
|
||||
),
|
||||
}),
|
||||
providesTags: (result, error, arg) => [{ type: 'ImageNameList', id: JSON.stringify(arg) }, 'FetchOnReconnect'],
|
||||
@@ -67,14 +68,12 @@ export const boardsApi = api.injectEndpoints({
|
||||
board_id: board_id ?? 'none',
|
||||
categories: IMAGE_CATEGORIES,
|
||||
is_intermediate: false,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
}),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: (result, error, arg) => [{ type: 'BoardImagesTotal', id: arg ?? 'none' }, 'FetchOnReconnect'],
|
||||
transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => {
|
||||
return { total: response.total };
|
||||
transformResponse: (response: GetImageNamesResult) => {
|
||||
return { total: response.total_count };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -84,14 +83,12 @@ export const boardsApi = api.injectEndpoints({
|
||||
board_id: board_id ?? 'none',
|
||||
categories: ASSETS_CATEGORIES,
|
||||
is_intermediate: false,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
}),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: (result, error, arg) => [{ type: 'BoardAssetsTotal', id: arg ?? 'none' }, 'FetchOnReconnect'],
|
||||
transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => {
|
||||
return { total: response.total };
|
||||
transformResponse: (response: GetImageNamesResult) => {
|
||||
return { total: response.total_count };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -134,5 +131,5 @@ export const {
|
||||
useGetBoardAssetsTotalQuery,
|
||||
useCreateBoardMutation,
|
||||
useUpdateBoardMutation,
|
||||
useListAllImageNamesForBoardQuery,
|
||||
useListAllResourceIdsForBoardQuery,
|
||||
} = boardsApi;
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
ImageDTO,
|
||||
ImageUploadEntryRequest,
|
||||
ImageUploadEntryResponse,
|
||||
ListImagesArgs,
|
||||
ListImagesResponse,
|
||||
UploadImageArg,
|
||||
} from 'services/api/types';
|
||||
import { getListImagesUrl } from 'services/api/util';
|
||||
@@ -32,50 +30,8 @@ import { buildBoardsUrl } from './boards';
|
||||
const buildImagesUrl = (path: string = '', query?: Parameters<typeof buildV1Url>[1]) =>
|
||||
buildV1Url(`images/${path}`, query);
|
||||
|
||||
/**
|
||||
* Builds an endpoint URL for the board_images router
|
||||
* @example
|
||||
* buildBoardImagesUrl('some-path')
|
||||
* // '/api/v1/board_images/some-path'
|
||||
*/
|
||||
const buildBoardImagesUrl = (path: string = '') => buildV1Url(`board_images/${path}`);
|
||||
|
||||
export const imagesApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
/**
|
||||
* Image Queries
|
||||
*/
|
||||
listImages: build.query<ListImagesResponse, ListImagesArgs>({
|
||||
query: (queryArgs) => ({
|
||||
// Use the helper to create the URL.
|
||||
url: getListImagesUrl(queryArgs),
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: (result, error, queryArgs) => {
|
||||
return [
|
||||
// Make the tags the same as the cache key
|
||||
{ type: 'ImageList', id: stableHash(queryArgs) },
|
||||
{ type: 'Board', id: queryArgs.board_id ?? 'none' },
|
||||
'FetchOnReconnect',
|
||||
];
|
||||
},
|
||||
async onQueryStarted(_, { dispatch, queryFulfilled }) {
|
||||
// Populate the getImageDTO cache with these images. This makes image selection smoother, because it doesn't
|
||||
// need to re-fetch image data when the user selects an image. The getImageDTO cache keeps data for the default
|
||||
// of 60s, so this data won't stick around too long.
|
||||
const res = await queryFulfilled;
|
||||
const imageDTOs = res.data.items;
|
||||
const updates: Param0<typeof imagesApi.util.upsertQueryEntries> = [];
|
||||
for (const imageDTO of imageDTOs) {
|
||||
updates.push({
|
||||
endpointName: 'getImageDTO',
|
||||
arg: imageDTO.image_name,
|
||||
value: imageDTO,
|
||||
});
|
||||
}
|
||||
dispatch(imagesApi.util.upsertQueryEntries(updates));
|
||||
},
|
||||
}),
|
||||
getIntermediatesCount: build.query<number, void>({
|
||||
query: () => ({ url: buildImagesUrl('intermediates') }),
|
||||
providesTags: ['IntermediatesCount', 'FetchOnReconnect'],
|
||||
@@ -101,70 +57,7 @@ export const imagesApi = api.injectEndpoints({
|
||||
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}/workflow`) }),
|
||||
providesTags: (result, error, image_name) => [{ type: 'ImageWorkflow', id: image_name }],
|
||||
}),
|
||||
deleteImage: build.mutation<
|
||||
paths['/api/v1/images/i/{image_name}']['delete']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/i/{image_name}']['delete']['parameters']['path']
|
||||
>({
|
||||
query: ({ image_name }) => ({
|
||||
url: buildImagesUrl(`i/${image_name}`),
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
deleteImages: build.mutation<
|
||||
paths['/api/v1/images/delete']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/delete']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildImagesUrl('delete'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
deleteUncategorizedImages: build.mutation<
|
||||
paths['/api/v1/images/uncategorized']['delete']['responses']['200']['content']['application/json'],
|
||||
void
|
||||
>({
|
||||
query: () => ({ url: buildImagesUrl('uncategorized'), method: 'DELETE' }),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
|
||||
/**
|
||||
* Change an image's `is_intermediate` property.
|
||||
*/
|
||||
@@ -187,56 +80,7 @@ export const imagesApi = api.injectEndpoints({
|
||||
];
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Star a list of images.
|
||||
*/
|
||||
starImages: build.mutation<
|
||||
paths['/api/v1/images/star']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/star']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildImagesUrl('star'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.starred_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: 'starred' },
|
||||
{ type: 'ImageCollection', id: 'unstarred' },
|
||||
];
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Unstar a list of images.
|
||||
*/
|
||||
unstarImages: build.mutation<
|
||||
paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/images/unstar']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildImagesUrl('unstar'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.unstarred_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: 'starred' },
|
||||
{ type: 'ImageCollection', id: 'unstarred' },
|
||||
];
|
||||
},
|
||||
}),
|
||||
|
||||
uploadImage: build.mutation<
|
||||
paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'],
|
||||
UploadImageArg
|
||||
@@ -322,86 +166,6 @@ export const imagesApi = api.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: () => [{ type: 'Board', id: LIST_TAG }],
|
||||
}),
|
||||
addImageToBoard: build.mutation<
|
||||
paths['/api/v1/board_images/']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => {
|
||||
return {
|
||||
url: buildBoardImagesUrl(),
|
||||
method: 'POST',
|
||||
body,
|
||||
};
|
||||
},
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.added_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
removeImageFromBoard: build.mutation<
|
||||
paths['/api/v1/board_images/']['delete']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => {
|
||||
return {
|
||||
url: buildBoardImagesUrl(),
|
||||
method: 'DELETE',
|
||||
body,
|
||||
};
|
||||
},
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.removed_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
addImagesToBoard: build.mutation<
|
||||
paths['/api/v1/board_images/batch']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/batch']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildBoardImagesUrl('batch'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.added_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
removeImagesFromBoard: build.mutation<
|
||||
paths['/api/v1/board_images/batch/delete']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_images/batch/delete']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildBoardImagesUrl('batch/delete'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(result.removed_images),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
bulkDownloadImages: build.mutation<
|
||||
components['schemas']['ImagesDownloaded'],
|
||||
components['schemas']['Body_download_images_from_list']
|
||||
@@ -466,7 +230,6 @@ export const imagesApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetIntermediatesCountQuery,
|
||||
useListImagesQuery,
|
||||
useGetImageDTOQuery,
|
||||
useGetImageMetadataQuery,
|
||||
useGetImageWorkflowQuery,
|
||||
@@ -474,13 +237,8 @@ export const {
|
||||
useUploadImageMutation,
|
||||
useCreateImageUploadEntryMutation,
|
||||
useClearIntermediatesMutation,
|
||||
useAddImagesToBoardMutation,
|
||||
useRemoveImagesFromBoardMutation,
|
||||
useDeleteBoardAndImagesMutation,
|
||||
useDeleteUncategorizedImagesMutation,
|
||||
useDeleteBoardMutation,
|
||||
useStarImagesMutation,
|
||||
useUnstarImagesMutation,
|
||||
useBulkDownloadImagesMutation,
|
||||
useGetImageNamesQuery,
|
||||
useGetImageDTOsByNamesMutation,
|
||||
@@ -591,7 +349,7 @@ export const useImageDTO = (imageName: string | null | undefined) => {
|
||||
return imageDTO ?? null;
|
||||
};
|
||||
|
||||
const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => {
|
||||
export const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => {
|
||||
const tags: ApiTagDescription[] = [];
|
||||
|
||||
for (const image_name of image_names) {
|
||||
@@ -612,7 +370,7 @@ const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescr
|
||||
return tags;
|
||||
};
|
||||
|
||||
const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => {
|
||||
export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => {
|
||||
const tags: ApiTagDescription[] = ['ImageNameList'];
|
||||
|
||||
for (const board_id of affected_boards) {
|
||||
|
||||
178
invokeai/frontend/web/src/services/api/endpoints/resources.ts
Normal file
178
invokeai/frontend/web/src/services/api/endpoints/resources.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { paths } from 'services/api/schema';
|
||||
|
||||
import { api, buildV1Url, LIST_TAG } from '..';
|
||||
import { getTagsToInvalidateForBoardAffectingMutation,getTagsToInvalidateForImageMutation } from './images';
|
||||
import { getTagsToInvalidateForVideoMutation } from './videos';
|
||||
|
||||
/**
|
||||
* Builds an endpoint URL for the resources router
|
||||
* @example
|
||||
* buildResourcesUrl('some-path')
|
||||
* // '/api/v1/resources/some-path'
|
||||
*/
|
||||
const buildResourcesUrl = (path: string = '') => buildV1Url(`resources/${path}`);
|
||||
|
||||
/**
|
||||
* Builds an endpoint URL for the board_resources router
|
||||
* @example
|
||||
* buildBoardResourcesUrl('some-path')
|
||||
* // '/api/v1/board_resources/some-path'
|
||||
*/
|
||||
const buildBoardResourcesUrl = (path: string = '') => buildV1Url(`board_resources/${path}`);
|
||||
|
||||
export const resourcesApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
addResourcesToBoard: build.mutation<
|
||||
paths['/api/v1/board_resources/batch']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_resources/batch']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildBoardResourcesUrl('batch'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
const image_ids = result.added_resources.filter(r => r.resource_type === "image").map(r => r.resource_id);
|
||||
const video_ids = result.added_resources.filter(r => r.resource_type === "video").map(r => r.resource_id);
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(image_ids),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
...getTagsToInvalidateForVideoMutation(video_ids),
|
||||
];
|
||||
},
|
||||
}),
|
||||
removeResourcesFromBoard: build.mutation<
|
||||
paths['/api/v1/board_resources/batch/delete']['post']['responses']['201']['content']['application/json'],
|
||||
paths['/api/v1/board_resources/batch/delete']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildBoardResourcesUrl('batch/delete'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
const image_ids = result.removed_resources.filter(r => r.resource_type === "image").map(r => r.resource_id);
|
||||
const video_ids = result.removed_resources.filter(r => r.resource_type === "video").map(r => r.resource_id);
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(image_ids),
|
||||
...getTagsToInvalidateForVideoMutation(video_ids),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
];
|
||||
},
|
||||
}),
|
||||
deleteResources: build.mutation<
|
||||
paths['/api/v1/resources/delete']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/resources/delete']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildResourcesUrl('delete'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
deleteUncategorizedResources: build.mutation<
|
||||
paths['/api/v1/resources/uncategorized']['delete']['responses']['200']['content']['application/json'],
|
||||
void
|
||||
>({
|
||||
query: () => ({ url: buildResourcesUrl('uncategorized'), method: 'DELETE' }),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
// We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries
|
||||
// that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags
|
||||
// will force those queries to re-fetch, and the requests will of course 404.
|
||||
return [
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
];
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Star a list of images.
|
||||
*/
|
||||
starResources: build.mutation<
|
||||
paths['/api/v1/resources/star']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/resources/star']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildResourcesUrl('star'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
const image_ids = result.starred_resources.filter(r => r.resource_type === "image").map(r => r.resource_id);
|
||||
const video_ids = result.starred_resources.filter(r => r.resource_type === "video").map(r => r.resource_id);
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(image_ids),
|
||||
...getTagsToInvalidateForVideoMutation(video_ids),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: 'starred' },
|
||||
{ type: 'ImageCollection', id: 'unstarred' },
|
||||
];
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Unstar a list of images.
|
||||
*/
|
||||
unstarResources: build.mutation<
|
||||
paths['/api/v1/resources/unstar']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/resources/unstar']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildResourcesUrl('unstar'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
const image_ids = result.unstarred_resources.filter(r => r.resource_type === "image").map(r => r.resource_id);
|
||||
const video_ids = result.unstarred_resources.filter(r => r.resource_type === "video").map(r => r.resource_id);
|
||||
return [
|
||||
...getTagsToInvalidateForImageMutation(image_ids),
|
||||
...getTagsToInvalidateForVideoMutation(video_ids),
|
||||
...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: 'starred' },
|
||||
{ type: 'ImageCollection', id: 'unstarred' },
|
||||
];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useAddResourcesToBoardMutation,
|
||||
useRemoveResourcesFromBoardMutation,
|
||||
useDeleteResourcesMutation,
|
||||
useDeleteUncategorizedResourcesMutation,
|
||||
useStarResourcesMutation,
|
||||
useUnstarResourcesMutation,
|
||||
} = resourcesApi;
|
||||
|
||||
121
invokeai/frontend/web/src/services/api/endpoints/videos.ts
Normal file
121
invokeai/frontend/web/src/services/api/endpoints/videos.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { paths } from 'services/api/schema';
|
||||
|
||||
import type { ApiTagDescription } from '..';
|
||||
import { api, buildV1Url } from '..';
|
||||
|
||||
/**
|
||||
* Builds an endpoint URL for the videos router
|
||||
* @example
|
||||
* buildVideosUrl('some-path')
|
||||
* // '/api/v1/videos/some-path'
|
||||
*/
|
||||
const buildVideosUrl = (path: string = '', query?: Parameters<typeof buildV1Url>[1]) =>
|
||||
buildV1Url(`videos/${path}`, query);
|
||||
|
||||
export const videosApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
/**
|
||||
* Video Queries
|
||||
*/
|
||||
listVideos: build.query<
|
||||
paths['/api/v1/videos/']['get']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/videos/']['get']['parameters']['query']
|
||||
>({
|
||||
query: (queryArgs) => ({
|
||||
url: buildVideosUrl(),
|
||||
method: 'GET',
|
||||
params: queryArgs,
|
||||
}),
|
||||
providesTags: (result, error, queryArgs) => {
|
||||
const tags: ApiTagDescription[] = [
|
||||
{ type: 'VideoList', id: JSON.stringify(queryArgs) },
|
||||
{ type: 'Board', id: queryArgs?.board_id ?? 'none' },
|
||||
'FetchOnReconnect',
|
||||
];
|
||||
return tags;
|
||||
},
|
||||
}),
|
||||
|
||||
getVideoDTO: build.query<
|
||||
paths['/api/v1/videos/i/{video_id}']['get']['responses']['200']['content']['application/json'],
|
||||
string
|
||||
>({
|
||||
query: (video_id) => ({ url: buildVideosUrl(`i/${video_id}`) }),
|
||||
providesTags: (result, error, video_id) => [
|
||||
{ type: 'Video', id: video_id },
|
||||
'FetchOnReconnect',
|
||||
],
|
||||
}),
|
||||
|
||||
getVideoIds: build.query<
|
||||
paths['/api/v1/videos/ids']['get']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/videos/ids']['get']['parameters']['query']
|
||||
>({
|
||||
query: (queryArgs) => ({
|
||||
url: buildVideosUrl('ids'),
|
||||
method: 'GET',
|
||||
params: queryArgs,
|
||||
}),
|
||||
providesTags: (result, error, queryArgs) => [
|
||||
{ type: 'VideoNameList', id: JSON.stringify(queryArgs) },
|
||||
'FetchOnReconnect',
|
||||
],
|
||||
}),
|
||||
|
||||
getVideosByIds: build.query<
|
||||
paths['/api/v1/videos/videos_by_ids']['post']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/videos/videos_by_ids']['post']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: (body) => ({
|
||||
url: buildVideosUrl('videos_by_ids'),
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
|
||||
}),
|
||||
|
||||
/**
|
||||
* Video Mutations
|
||||
*/
|
||||
|
||||
updateVideo: build.mutation<
|
||||
paths['/api/v1/videos/i/{video_id}']['patch']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/videos/i/{video_id}']['patch']['parameters']['path'] &
|
||||
paths['/api/v1/videos/i/{video_id}']['patch']['requestBody']['content']['application/json']
|
||||
>({
|
||||
query: ({ video_id, ...body }) => ({
|
||||
url: buildVideosUrl(`i/${video_id}`),
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
|
||||
}),
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useListVideosQuery,
|
||||
useGetVideoDTOQuery,
|
||||
useGetVideoIdsQuery,
|
||||
useGetVideosByIdsQuery,
|
||||
useUpdateVideoMutation,
|
||||
} = videosApi;
|
||||
|
||||
export const getTagsToInvalidateForVideoMutation = (video_ids: string[]): ApiTagDescription[] => {
|
||||
const tags: ApiTagDescription[] = [];
|
||||
|
||||
for (const video_id of video_ids) {
|
||||
tags.push({
|
||||
type: 'Video',
|
||||
id: video_id,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return tags;
|
||||
};
|
||||
@@ -54,6 +54,9 @@ const tagTypes = [
|
||||
'StylePreset',
|
||||
'Schema',
|
||||
'QueueCountsByDestination',
|
||||
'Video',
|
||||
'VideoList',
|
||||
'VideoNameList',
|
||||
// This is invalidated on reconnect. It should be used for queries that have changing data,
|
||||
// especially related to the queue and generation.
|
||||
'FetchOnReconnect',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,7 @@ import z from 'zod';
|
||||
|
||||
export type S = components['schemas'];
|
||||
|
||||
export type ListImagesArgs = NonNullable<paths['/api/v1/images/']['get']['parameters']['query']>;
|
||||
export type ListImagesResponse = paths['/api/v1/images/']['get']['responses']['200']['content']['application/json'];
|
||||
export type ListImagesArgs = NonNullable<paths['/api/v1/images/names']['get']['parameters']['query']>;
|
||||
|
||||
export type GetImageNamesResult =
|
||||
paths['/api/v1/images/names']['get']['responses']['200']['content']['application/json'];
|
||||
@@ -45,6 +44,7 @@ assert<Equals<ImageCategory, S['ImageCategory']>>();
|
||||
|
||||
// Images
|
||||
const _zImageDTO = z.object({
|
||||
type: z.literal('image'),
|
||||
image_name: z.string(),
|
||||
image_url: z.string(),
|
||||
thumbnail_url: z.string(),
|
||||
@@ -65,8 +65,18 @@ const _zImageDTO = z.object({
|
||||
export type ImageDTO = z.infer<typeof _zImageDTO>;
|
||||
assert<Equals<ImageDTO, S['ImageDTO']>>();
|
||||
|
||||
const _zVideoDTO = z.object({
|
||||
type: z.literal('video'),
|
||||
video_id: z.string(),
|
||||
width: z.number().int().gt(0),
|
||||
height: z.number().int().gt(0),
|
||||
board_id: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type VideoDTO = z.infer<typeof _zVideoDTO>;
|
||||
assert<Equals<VideoDTO, S['VideoDTO']>>();
|
||||
|
||||
export type BoardDTO = S['BoardDTO'];
|
||||
export type OffsetPaginatedResults_ImageDTO_ = S['OffsetPaginatedResults_ImageDTO_'];
|
||||
|
||||
// Models
|
||||
export type ModelType = S['ModelType'];
|
||||
@@ -434,3 +444,5 @@ export type UploadImageArg = {
|
||||
|
||||
export type ImageUploadEntryResponse = S['ImageUploadEntry'];
|
||||
export type ImageUploadEntryRequest = paths['/api/v1/images/']['post']['requestBody']['content']['application/json'];
|
||||
|
||||
export type ResourceType = S['ResourceType'];
|
||||
|
||||
@@ -13,4 +13,4 @@ export const getCategories = (imageDTO: ImageDTO) => {
|
||||
|
||||
// Helper to create the url for the listImages endpoint. Also we use it to create the cache key.
|
||||
export const getListImagesUrl = (queryArgs: ListImagesArgs) =>
|
||||
buildV1Url(`images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`);
|
||||
buildV1Url(`images/names/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`);
|
||||
|
||||
@@ -15,7 +15,8 @@ import safetensors.torch
|
||||
import torch
|
||||
|
||||
import invokeai.backend.quantization.gguf.loaders as gguf_loaders
|
||||
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
|
||||
# from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
@@ -39,8 +40,8 @@ def mock_services() -> InvocationServices:
|
||||
|
||||
# NOTE: none of these are actually called by the test invocations
|
||||
return InvocationServices(
|
||||
board_image_records=SqliteBoardImageRecordStorage(db=db),
|
||||
board_images=None, # type: ignore
|
||||
# board_image_records=SqliteBoardImageRecordStorage(db=db),
|
||||
# board_images=None, # type: ignore
|
||||
board_records=SqliteBoardRecordStorage(db=db),
|
||||
boards=None, # type: ignore
|
||||
bulk_download=BulkDownloadService(),
|
||||
|
||||
Reference in New Issue
Block a user