Compare commits

...

14 Commits

Author SHA1 Message Date
psychedelicious
e048ff1746 fix(ui): ImageDTO mocks 2025-08-14 22:13:41 +10:00
psychedelicious
ecbe948f0c feat(ui): resource id helper 2025-08-14 22:13:41 +10:00
psychedelicious
440a8bdc5f chore(ui): lint 2025-08-14 22:13:41 +10:00
psychedelicious
1eae96675b chore: ruff 2025-08-14 22:13:41 +10:00
psychedelicious
8b6cbb714e feat: add type discriminator field to ImageDTO and VideoDTO 2025-08-14 22:13:41 +10:00
Mary Hipp
5fff9d3245 cleanup GalleryItem rendering to branch on either Images or Videos, more cleanup 2025-08-14 22:13:41 +10:00
Mary Hipp
a0f5189d61 POC for unifying videos and images for board and bulk operations and gallery display 2025-08-14 22:13:40 +10:00
Mary Hipp
6afe995dbe combine nodes that generate and save videos 2025-08-14 22:13:40 +10:00
Mary Hipp
d8fcf18b6c build out adhoc video saving graph 2025-08-14 22:13:40 +10:00
Mary Hipp
b3dbf5b914 push up updates for VideoField 2025-08-14 22:13:40 +10:00
Mary Hipp
d81716f0b6 update VideoField 2025-08-14 22:13:40 +10:00
Mary Hipp
d86617241d split out RunwayVideoOutput from VideoOutput 2025-08-14 22:13:40 +10:00
Mary Hipp
b47544968e rough rough POC of video tab 2025-08-14 22:13:40 +10:00
Mary Hipp
4d5604df48 video_output support 2025-08-14 22:13:40 +10:00
73 changed files with 3118 additions and 1399 deletions

View File

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

View 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")

View File

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

View File

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

View 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")

View 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")

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
from enum import Enum, EnumMeta
class ResourceType(str, Enum, metaclass=EnumMeta):
"""Enum for resource types."""
IMAGE = "image"
LATENT = "latent"

View 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")

View File

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

View 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

View 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")

View 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

View File

View 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

View 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,
)

View 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=[])

View File

@@ -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.",

View File

@@ -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());

View File

@@ -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');
},
});
};

View File

@@ -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');
},
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
};

View File

@@ -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([]));
};

View File

@@ -188,3 +188,4 @@ export const zImageOutput = z.object({
});
export type ImageOutput = z.infer<typeof zImageOutput>;
// #endregion

View File

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

View File

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

View File

@@ -87,3 +87,4 @@ export const selectWithModelsTab = createSelector(selectDidLoad, selectDisabledT
export const selectWithQueueTab = createSelector(selectDidLoad, selectDisabledTabs, (didLoad, disabledTabs) =>
didLoad ? !disabledTabs.includes('queue') : false
);

View File

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

View File

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

View 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;

View 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;
};

View File

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

View File

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

View File

@@ -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' })}`);

View File

@@ -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(),