From 845039e5441fc20fc4be8007a8a890246f51e9ab Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:50:34 +1000 Subject: [PATCH 1/6] feat(app): add method & route to get uncategorized image counts --- invokeai/app/api/routers/boards.py | 13 +++++++++- .../board_records/board_records_base.py | 7 ++++- .../board_records/board_records_common.py | 5 ++++ .../board_records/board_records_sqlite.py | 26 +++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 926c0f7fd22..d5b1acb5144 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.board_records.board_records_common import BoardChanges +from invokeai.app.services.board_records.board_records_common import BoardChanges, UncategorizedImageCounts from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -146,3 +146,14 @@ async def list_all_board_image_names( board_id, ) return image_names + + +@boards_router.get( + "/uncategorized/counts", + operation_id="get_uncategorized_image_counts", + response_model=UncategorizedImageCounts, +) +async def get_uncategorized_image_counts() -> UncategorizedImageCounts: + """Gets count of images and assets for uncategorized images (images with no board assocation)""" + + return ApiDependencies.invoker.services.board_records.get_uncategorized_image_counts() diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py index 9d16dacf60b..7bfe6ada6fd 100644 --- a/invokeai/app/services/board_records/board_records_base.py +++ b/invokeai/app/services/board_records/board_records_base.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord +from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord, UncategorizedImageCounts from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -48,3 +48,8 @@ def get_many( def get_all(self, include_archived: bool = False) -> list[BoardRecord]: """Gets all board records.""" pass + + @abstractmethod + def get_uncategorized_image_counts(self) -> UncategorizedImageCounts: + """Gets count of images and assets for uncategorized images (images with no board assocation).""" + pass diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index 0dda8a8b6b6..1c25aab5650 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -79,3 +79,8 @@ class BoardRecordDeleteException(Exception): def __init__(self, message="Board record not deleted"): super().__init__(message) + + +class UncategorizedImageCounts(BaseModel): + image_count: int = Field(description="The number of uncategorized images.") + asset_count: int = Field(description="The number of uncategorized assets.") diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py index c64e060b953..895ffeb17d4 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -9,6 +9,7 @@ BoardRecordDeleteException, BoardRecordNotFoundException, BoardRecordSaveException, + UncategorizedImageCounts, deserialize_board_record, ) from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -228,3 +229,28 @@ def get_all(self, include_archived: bool = False) -> list[BoardRecord]: raise e finally: self._lock.release() + + def get_uncategorized_image_counts(self) -> UncategorizedImageCounts: + try: + self._lock.acquire() + query = """ + -- Get the count of uncategorized images and assets. + SELECT + CASE + WHEN i.image_category = 'general' THEN 'image_count' -- "Images" are images in the 'general' category + ELSE 'asset_count' -- "Assets" are images in any of the other categories ('control', 'mask', 'user', 'other') + END AS category_type, + COUNT(*) AS unassigned_count + FROM images i + LEFT JOIN board_images bi ON i.image_name = bi.image_name + WHERE bi.board_id IS NULL -- Uncategorized images have no board association + AND i.is_intermediate = 0 -- Omit intermediates from the counts + GROUP BY category_type; -- Group by category_type alias, as derived from the image_category column earlier + """ + self._cursor.execute(query) + results = self._cursor.fetchall() + image_count = dict(results)["image_count"] + asset_count = dict(results)["asset_count"] + return UncategorizedImageCounts(image_count=image_count, asset_count=asset_count) + finally: + self._lock.release() From fcac493afe363ed33a5c2654c51030376417fd9f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:31:26 +1000 Subject: [PATCH 2/6] feat(app): optimize boards queries Use SQL instead of python to retrieve image count, asset count and board cover image. This reduces the number of SQL queries needed to list all boards. Previously, we did `1 + 2 * board_count` queries:: - 1 query to get the list of board records - 1 query per board to get its total count - 1 query per board to get its cover image Then, on the frontend, we made two additional network requests to get each board's counts: - 1 request (== 1 SQL query) for image count - 1 request (== 1 SQL query) for asset count All of this information is now retrieved in a single SQL query, and provided via single network request. As part of this change, `BoardRecord` now includes `image_count`, `asset_count` and `cover_image_name`. This makes `BoardDTO` redundant, but removing it is a deeper change... --- .../board_records/board_records_common.py | 18 +- .../board_records/board_records_sqlite.py | 174 ++++++++++++------ invokeai/app/services/boards/boards_common.py | 21 +-- .../app/services/boards/boards_default.py | 45 +---- .../bulk_download/test_bulk_download.py | 22 ++- 5 files changed, 157 insertions(+), 123 deletions(-) diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index 1c25aab5650..3478746536f 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional, Union +from typing import Any, Optional, Union from pydantic import BaseModel, Field @@ -26,21 +26,25 @@ class BoardRecord(BaseModelExcludeNull): """Whether or not the board is archived.""" is_private: Optional[bool] = Field(default=None, description="Whether the board is private.") """Whether the board is private.""" + image_count: int = Field(description="The number of images in the board.") + asset_count: int = Field(description="The number of assets in the board.") -def deserialize_board_record(board_dict: dict) -> BoardRecord: +def deserialize_board_record(board_dict: dict[str, Any]) -> BoardRecord: """Deserializes a board record.""" # Retrieve all the values, setting "reasonable" defaults if they are not present. board_id = board_dict.get("board_id", "unknown") board_name = board_dict.get("board_name", "unknown") - cover_image_name = board_dict.get("cover_image_name", "unknown") + cover_image_name = board_dict.get("cover_image_name", None) created_at = board_dict.get("created_at", get_iso_timestamp()) updated_at = board_dict.get("updated_at", get_iso_timestamp()) deleted_at = board_dict.get("deleted_at", get_iso_timestamp()) archived = board_dict.get("archived", False) is_private = board_dict.get("is_private", False) + image_count = board_dict.get("image_count", 0) + asset_count = board_dict.get("asset_count", 0) return BoardRecord( board_id=board_id, @@ -51,6 +55,8 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: deleted_at=deleted_at, archived=archived, is_private=is_private, + image_count=image_count, + asset_count=asset_count, ) @@ -63,21 +69,21 @@ class BoardChanges(BaseModel, extra="forbid"): class BoardRecordNotFoundException(Exception): """Raised when an board record is not found.""" - def __init__(self, message="Board record not found"): + def __init__(self, message: str = "Board record not found"): super().__init__(message) class BoardRecordSaveException(Exception): """Raised when an board record cannot be saved.""" - def __init__(self, message="Board record not saved"): + def __init__(self, message: str = "Board record not saved"): super().__init__(message) class BoardRecordDeleteException(Exception): """Raised when an board record cannot be deleted.""" - def __init__(self, message="Board record not deleted"): + def __init__(self, message: str = "Board record not deleted"): super().__init__(message) diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py index 895ffeb17d4..ea02d3e4b21 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -16,6 +16,114 @@ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase from invokeai.app.util.misc import uuid_string +_BASE_BOARD_RECORD_QUERY = """ + -- This query retrieves board records, joining with the board_images and images tables to get image counts and cover image names. + -- It is not a complete query, as it is missing a GROUP BY or WHERE clause (and is unterminated). + SELECT b.board_id, + b.board_name, + b.created_at, + b.updated_at, + b.archived, + -- Count the number of images in the board, alias image_count + COUNT( + CASE + WHEN i.image_category in ('general') -- "Images" are images in the 'general' category + AND i.is_intermediate = 0 THEN 1 -- Intermediates are not counted + END + ) AS image_count, + -- Count the number of assets in the board, alias asset_count + COUNT( + CASE + WHEN i.image_category in ('control', 'mask', 'user', 'other') -- "Assets" are images in any of the other categories ('control', 'mask', 'user', 'other') + AND i.is_intermediate = 0 THEN 1 -- Intermediates are not counted + END + ) AS asset_count, + -- Get the name of the the most recent image in the board, alias cover_image_name + ( + SELECT bi.image_name + FROM board_images bi + JOIN images i ON bi.image_name = i.image_name + WHERE bi.board_id = b.board_id + AND i.is_intermediate = 0 -- Intermediates cannot be cover images + ORDER BY i.created_at DESC -- Sort by created_at to get the most recent image + LIMIT 1 + ) AS cover_image_name + FROM boards b + LEFT JOIN board_images bi ON b.board_id = bi.board_id + LEFT JOIN images i ON bi.image_name = i.image_name + """ + + +def get_paginated_list_board_records_queries(include_archived: bool) -> str: + """Gets a query to retrieve a paginated list of board records. The query has placeholders for limit and offset. + + Args: + include_archived: Whether to include archived board records in the results. + + Returns: + A query to retrieve a paginated list of board records. + """ + + archived_condition = "WHERE b.archived = 0" if not include_archived else "" + + # The GROUP BY must be added _after_ the WHERE clause! + query = f""" + {_BASE_BOARD_RECORD_QUERY} + {archived_condition} + GROUP BY b.board_id, + b.board_name, + b.created_at, + b.updated_at + ORDER BY b.created_at DESC + LIMIT ? OFFSET ?; + """ + + return query + + +def get_total_boards_count_query(include_archived: bool) -> str: + """Gets a query to retrieve the total count of board records. + + Args: + include_archived: Whether to include archived board records in the count. + + Returns: + A query to retrieve the total count of board records. + """ + + archived_condition = "WHERE b.archived = 0" if not include_archived else "" + + return f"SELECT COUNT(*) FROM boards {archived_condition};" + + +def get_list_all_board_records_query(include_archived: bool) -> str: + """Gets a query to retrieve all board records. + + Args: + include_archived: Whether to include archived board records in the results. + + Returns: + A query to retrieve all board records. + """ + + archived_condition = "WHERE b.archived = 0" if not include_archived else "" + + return f""" + {_BASE_BOARD_RECORD_QUERY} + {archived_condition} + GROUP BY b.board_id, + b.board_name, + b.created_at, + b.updated_at + ORDER BY b.created_at DESC; + """ + + +def get_board_record_query() -> str: + """Gets a query to retrieve a board record. The query has a placeholder for the board_id.""" + + return f"{_BASE_BOARD_RECORD_QUERY} WHERE b.board_id = ?;" + class SqliteBoardRecordStorage(BoardRecordStorageBase): _conn: sqlite3.Connection @@ -77,11 +185,7 @@ def get( try: self._lock.acquire() self._cursor.execute( - """--sql - SELECT * - FROM boards - WHERE board_id = ?; - """, + get_board_record_query(), (board_id,), ) @@ -93,7 +197,7 @@ def get( self._lock.release() if result is None: raise BoardRecordNotFoundException - return BoardRecord(**dict(result)) + return deserialize_board_record(dict(result)) def update( self, @@ -150,45 +254,15 @@ def get_many( try: self._lock.acquire() - # Build base query - base_query = """ - SELECT * - FROM boards - {archived_filter} - ORDER BY created_at DESC - LIMIT ? OFFSET ?; - """ - - # Determine archived filter condition - if include_archived: - archived_filter = "" - else: - archived_filter = "WHERE archived = 0" + main_query = get_paginated_list_board_records_queries(include_archived=include_archived) - final_query = base_query.format(archived_filter=archived_filter) - - # Execute query to fetch boards - self._cursor.execute(final_query, (limit, offset)) + self._cursor.execute(main_query, (limit, offset)) result = cast(list[sqlite3.Row], self._cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] - # Determine count query - if include_archived: - count_query = """ - SELECT COUNT(*) - FROM boards; - """ - else: - count_query = """ - SELECT COUNT(*) - FROM boards - WHERE archived = 0; - """ - - # Execute count query - self._cursor.execute(count_query) - + total_query = get_total_boards_count_query(include_archived=include_archived) + self._cursor.execute(total_query) count = cast(int, self._cursor.fetchone()[0]) return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count) @@ -202,26 +276,10 @@ def get_many( def get_all(self, include_archived: bool = False) -> list[BoardRecord]: try: self._lock.acquire() - - base_query = """ - SELECT * - FROM boards - {archived_filter} - ORDER BY created_at DESC - """ - - if include_archived: - archived_filter = "" - else: - archived_filter = "WHERE archived = 0" - - final_query = base_query.format(archived_filter=archived_filter) - - self._cursor.execute(final_query) - + query = get_list_all_board_records_query(include_archived=include_archived) + self._cursor.execute(query) result = cast(list[sqlite3.Row], self._cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] - return boards except sqlite3.Error as e: diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py index 15d0b3c37f5..1e9337a3edf 100644 --- a/invokeai/app/services/boards/boards_common.py +++ b/invokeai/app/services/boards/boards_common.py @@ -1,23 +1,8 @@ -from typing import Optional - -from pydantic import Field - from invokeai.app.services.board_records.board_records_common import BoardRecord +# TODO(psyche): BoardDTO is now identical to BoardRecord. We should consider removing it. class BoardDTO(BoardRecord): - """Deserialized board record with cover image URL and image count.""" - - cover_image_name: Optional[str] = Field(description="The name of the board's cover image.") - """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.""" - + """Deserialized board record.""" -def board_record_to_dto(board_record: BoardRecord, cover_image_name: Optional[str], image_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, - ) + pass diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py index 97fd3059a93..abf38e8ea71 100644 --- a/invokeai/app/services/boards/boards_default.py +++ b/invokeai/app/services/boards/boards_default.py @@ -1,6 +1,6 @@ from invokeai.app.services.board_records.board_records_common import BoardChanges 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.boards.boards_common import BoardDTO from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -16,17 +16,11 @@ def create( board_name: str, ) -> BoardDTO: board_record = self.__invoker.services.board_records.save(board_name) - return board_record_to_dto(board_record, None, 0) + return BoardDTO.model_validate(board_record.model_dump()) def get_dto(self, board_id: str) -> BoardDTO: board_record = self.__invoker.services.board_records.get(board_id) - cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(board_record.board_id) - if cover_image: - 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) + return BoardDTO.model_validate(board_record.model_dump()) def update( self, @@ -34,14 +28,7 @@ def update( changes: BoardChanges, ) -> BoardDTO: board_record = self.__invoker.services.board_records.update(board_id, changes) - cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(board_record.board_id) - if cover_image: - 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) + return BoardDTO.model_validate(board_record.model_dump()) def delete(self, board_id: str) -> None: self.__invoker.services.board_records.delete(board_id) @@ -50,30 +37,10 @@ def get_many( self, offset: int = 0, limit: int = 10, include_archived: bool = False ) -> OffsetPaginatedResults[BoardDTO]: board_records = self.__invoker.services.board_records.get_many(offset, limit, include_archived) - board_dtos = [] - 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: - 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(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count)) - + board_dtos = [BoardDTO.model_validate(r.model_dump()) for r in board_records.items] return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos)) def get_all(self, include_archived: bool = False) -> list[BoardDTO]: board_records = self.__invoker.services.board_records.get_all(include_archived) - board_dtos = [] - for r in board_records: - cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id) - if cover_image: - 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(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count)) - + board_dtos = [BoardDTO.model_validate(r.model_dump()) for r in board_records] return board_dtos diff --git a/tests/app/services/bulk_download/test_bulk_download.py b/tests/app/services/bulk_download/test_bulk_download.py index 223ecc88632..48842d9a4bd 100644 --- a/tests/app/services/bulk_download/test_bulk_download.py +++ b/tests/app/services/bulk_download/test_bulk_download.py @@ -127,7 +127,16 @@ def test_generate_id_with_board_id(monkeypatch: Any, mock_invoker: Invoker): def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + created_at="None", + updated_at="None", + archived=False, + asset_count=0, + image_count=0, + cover_image_name="asdf.png", + deleted_at=None, + is_private=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get) @@ -156,7 +165,16 @@ def test_handler_board_id(tmp_path: Path, monkeypatch: Any, mock_image_dto: Imag def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + created_at="None", + updated_at="None", + archived=False, + asset_count=0, + image_count=0, + cover_image_name="asdf.png", + deleted_at=None, + is_private=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get) From d2f81811a16228b70fe12c71a01483ca91079117 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:31:58 +1000 Subject: [PATCH 3/6] chore(ui): typegen --- .../frontend/web/src/services/api/schema.ts | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b34e9589a4c..e483d678759 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -785,6 +785,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/boards/uncategorized/counts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Uncategorized Image Counts + * @description Gets count of images and assets for uncategorized images (images with no board assocation) + */ + get: operations["get_uncategorized_image_counts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/board_images/": { parameters: { query?: never; @@ -1973,7 +1993,7 @@ export type components = { }; /** * BoardDTO - * @description Deserialized board record with cover image URL and image count. + * @description Deserialized board record. */ BoardDTO: { /** @@ -2003,9 +2023,9 @@ export type components = { deleted_at?: string | null; /** * Cover Image Name - * @description The name of the board's cover image. + * @description The name of the cover image of the board. */ - cover_image_name: string | null; + cover_image_name?: string | null; /** * Archived * @description Whether or not the board is archived. @@ -2021,6 +2041,11 @@ export type components = { * @description The number of images in the board. */ image_count: number; + /** + * Asset Count + * @description The number of assets in the board. + */ + asset_count: number; }; /** * BoardField @@ -4345,7 +4370,7 @@ export type components = { }; /** * Core Metadata - * @description Collects core generation metadata into a MetadataField + * @description Used internally by Invoke to collect metadata for generations. */ CoreMetadataInvocation: { /** @@ -16391,6 +16416,19 @@ export type components = { */ type: "url"; }; + /** UncategorizedImageCounts */ + UncategorizedImageCounts: { + /** + * Image Count + * @description The number of uncategorized images. + */ + image_count: number; + /** + * Asset Count + * @description The number of uncategorized assets. + */ + asset_count: number; + }; /** * Unsharp Mask * @description Applies an unsharp mask filter to an image @@ -18845,6 +18883,26 @@ export interface operations { }; }; }; + get_uncategorized_image_counts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UncategorizedImageCounts"]; + }; + }; + }; + }; add_image_to_board: { parameters: { query?: never; From 9a36b3debdddb214ecaf2147e3be239e12391002 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:37:11 +1000 Subject: [PATCH 4/6] feat(ui): use updated boards data - Update tooltips to use counts in the DTO - Remove unused `getBoardImagesTotal` and `getBoardAssetsTotal` queries, which were just abusing the list endpoint to get totals... - Remove extraneous optimistic update in invocation complete listener --- .../Boards/BoardsList/BoardTotalsTooltip.tsx | 12 +++++ .../Boards/BoardsList/GalleryBoard.tsx | 17 +++++-- .../Boards/BoardsList/NoBoardBoard.tsx | 25 ++++++---- .../web/src/services/api/endpoints/boards.ts | 48 +++---------------- .../frontend/web/src/services/api/index.ts | 1 + .../frontend/web/src/services/api/types.ts | 1 - .../services/events/onInvocationComplete.tsx | 8 ---- 7 files changed, 49 insertions(+), 63 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx new file mode 100644 index 00000000000..01d6c226dd1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx @@ -0,0 +1,12 @@ +import { useTranslation } from 'react-i18next'; + +type Props = { + imageCount: number; + assetCount: number; + isArchived: boolean; +}; + +export const BoardTotalsTooltip = ({ imageCount, assetCount, isArchived }: Props) => { + const { t } = useTranslation(); + return `${t('boards.imagesWithCount', { count: imageCount })}, ${t('boards.assetsWithCount', { count: assetCount })}${isArchived ? ` (${t('boards.archived')})` : ''}`; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 2bd07ef39cc..6f0c4c80d7e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -17,7 +17,7 @@ import IAIDroppable from 'common/components/IAIDroppable'; import type { AddToBoardDropData } from 'features/dnd/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; -import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; +import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip'; import { selectAutoAddBoardId, selectAutoAssignBoardOnClick, @@ -119,7 +119,18 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { return ( {(ref) => ( - } openDelay={1000} placement="left" closeOnScroll p={2}> + + } + openDelay={1000} + placement="left" + closeOnScroll + > { {autoAddBoardId === board.board_id && !editingDisclosure.isOpen && } {board.archived && !editingDisclosure.isOpen && } - {!editingDisclosure.isOpen && {board.image_count}} + {!editingDisclosure.isOpen && {board.image_count + board.asset_count}} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index 7719c79866f..32cef15838d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -4,7 +4,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIDroppable from 'common/components/IAIDroppable'; import type { RemoveFromBoardDropData } from 'features/dnd/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; -import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; +import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip'; import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; import { selectAutoAddBoardId, @@ -14,7 +14,7 @@ import { import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; +import { useGetUncategorizedImageCountsQuery } from 'services/api/endpoints/boards'; import { useBoardName } from 'services/api/hooks/useBoardName'; interface Props { @@ -27,11 +27,7 @@ const _hover: SystemStyleObject = { const NoBoardBoard = memo(({ isSelected }: Props) => { const dispatch = useAppDispatch(); - const { imagesTotal } = useGetBoardImagesTotalQuery('none', { - selectFromResult: ({ data }) => { - return { imagesTotal: data?.total ?? 0 }; - }, - }); + const { data } = useGetUncategorizedImageCountsQuery(); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); const boardSearchText = useAppSelector(selectBoardSearchText); @@ -60,7 +56,18 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { return ( {(ref) => ( - } openDelay={1000} placement="left" closeOnScroll> + + } + openDelay={1000} + placement="left" + closeOnScroll + > { {boardName} {autoAddBoardId === 'none' && } - {imagesTotal} + {(data?.image_count ?? 0) + (data?.asset_count ?? 0)} diff --git a/invokeai/frontend/web/src/services/api/endpoints/boards.ts b/invokeai/frontend/web/src/services/api/endpoints/boards.ts index 55ebeab3188..2b33a0a603f 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boards.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boards.ts @@ -1,12 +1,4 @@ -import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { - BoardDTO, - CreateBoardArg, - ListBoardsArgs, - OffsetPaginatedResults_ImageDTO_, - UpdateBoardArg, -} from 'services/api/types'; -import { getListImagesUrl } from 'services/api/util'; +import type { BoardDTO, CreateBoardArg, ListBoardsArgs, S, UpdateBoardArg } from 'services/api/types'; import type { ApiTagDescription } from '..'; import { api, buildV1Url, LIST_TAG } from '..'; @@ -55,38 +47,11 @@ export const boardsApi = api.injectEndpoints({ keepUnusedDataFor: 0, }), - getBoardImagesTotal: build.query<{ total: number }, string | undefined>({ - query: (board_id) => ({ - url: getListImagesUrl({ - board_id: board_id ?? 'none', - categories: IMAGE_CATEGORIES, - is_intermediate: false, - limit: 0, - offset: 0, - }), - method: 'GET', + getUncategorizedImageCounts: build.query({ + query: () => ({ + url: buildBoardsUrl('uncategorized/counts'), }), - providesTags: (result, error, arg) => [{ type: 'BoardImagesTotal', id: arg ?? 'none' }, 'FetchOnReconnect'], - transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { - return { total: response.total }; - }, - }), - - getBoardAssetsTotal: build.query<{ total: number }, string | undefined>({ - query: (board_id) => ({ - url: getListImagesUrl({ - 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 }; - }, + providesTags: ['UncategorizedImageCounts', { type: 'Board', id: LIST_TAG }, { type: 'Board', id: 'none' }], }), /** @@ -124,9 +89,8 @@ export const boardsApi = api.injectEndpoints({ export const { useListAllBoardsQuery, - useGetBoardImagesTotalQuery, - useGetBoardAssetsTotalQuery, useCreateBoardMutation, useUpdateBoardMutation, useListAllImageNamesForBoardQuery, + useGetUncategorizedImageCountsQuery, } = boardsApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 0b82714d94c..1fa78cc4d7e 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -46,6 +46,7 @@ const tagTypes = [ // This is invalidated on reconnect. It should be used for queries that have changing data, // especially related to the queue and generation. 'FetchOnReconnect', + 'UncategorizedImageCounts', ] as const; export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>; export const LIST_TAG = 'LIST'; diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 647f141f4f6..b0860d6678c 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -37,7 +37,6 @@ export type AppDependencyVersions = S['AppDependencyVersions']; export type ImageDTO = S['ImageDTO']; export type BoardDTO = S['BoardDTO']; export type ImageCategory = S['ImageCategory']; -export type OffsetPaginatedResults_ImageDTO_ = S['OffsetPaginatedResults_ImageDTO_']; // Models export type ModelType = S['ModelType']; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index f6fae1ec4a8..fc16c01cdc8 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -6,7 +6,6 @@ import { stagingAreaImageStaged } from 'features/controlLayers/store/canvasStagi import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState'; import { zNodeStatus } from 'features/nodes/types/invocation'; -import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; @@ -31,13 +30,6 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A return; } - // update the total images for the board - dispatch( - boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => { - draft.total += 1; - }) - ); - dispatch( imagesApi.util.invalidateTags([ { type: 'Board', id: imageDTO.board_id ?? 'none' }, From a0919423f18a61133ff1e0f791a5f0a08798fe5e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:07:36 +1000 Subject: [PATCH 5/6] fix(ui): use correct BoardTooltip component w/ thumbnail image --- .../Boards/BoardsList/BoardTooltip.tsx | 32 ++++++++----------- .../Boards/BoardsList/BoardTotalsTooltip.tsx | 12 ------- .../Boards/BoardsList/GalleryBoard.tsx | 9 +++--- .../Boards/BoardsList/NoBoardBoard.tsx | 10 ++---- 4 files changed, 21 insertions(+), 42 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx index 63ba2991cfc..e3b30666b0e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx @@ -1,27 +1,23 @@ import { Flex, Image, Text } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import type { BoardDTO } from 'services/api/types'; type Props = { - board: BoardDTO | null; + imageCount: number; + assetCount: number; + isArchived: boolean; + coverImageName?: string | null; }; -export const BoardTooltip = ({ board }: Props) => { +export const BoardTooltip = ({ imageCount, assetCount, isArchived, coverImageName }: Props) => { const { t } = useTranslation(); - const { imagesTotal } = useGetBoardImagesTotalQuery(board?.board_id || 'none', { - selectFromResult: ({ data }) => { - return { imagesTotal: data?.total ?? 0 }; - }, - }); - const { assetsTotal } = useGetBoardAssetsTotalQuery(board?.board_id || 'none', { - selectFromResult: ({ data }) => { - return { assetsTotal: data?.total ?? 0 }; - }, - }); - const { currentData: coverImage } = useGetImageDTOQuery(board?.cover_image_name ?? skipToken); + const { currentData: coverImage } = useGetImageDTOQuery(coverImageName ?? skipToken); + + const totalString = useMemo(() => { + return `${t('boards.imagesWithCount', { count: imageCount })}, ${t('boards.assetsWithCount', { count: assetCount })}${isArchived ? ` (${t('boards.archived')})` : ''}`; + }, [assetCount, imageCount, isArchived, t]); return ( @@ -34,13 +30,11 @@ export const BoardTooltip = ({ board }: Props) => { aspectRatio="1/1" borderRadius="base" borderBottomRadius="lg" + mt={1} /> )} - - {t('boards.imagesWithCount', { count: imagesTotal })}, {t('boards.assetsWithCount', { count: assetsTotal })} - - {board?.archived && ({t('boards.archived')})} + {totalString} ); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx deleted file mode 100644 index 01d6c226dd1..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -type Props = { - imageCount: number; - assetCount: number; - isArchived: boolean; -}; - -export const BoardTotalsTooltip = ({ imageCount, assetCount, isArchived }: Props) => { - const { t } = useTranslation(); - return `${t('boards.imagesWithCount', { count: imageCount })}, ${t('boards.assetsWithCount', { count: assetCount })}${isArchived ? ` (${t('boards.archived')})` : ''}`; -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 6f0c4c80d7e..9ae3d923544 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -17,7 +17,7 @@ import IAIDroppable from 'common/components/IAIDroppable'; import type { AddToBoardDropData } from 'features/dnd/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; -import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip'; +import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; import { selectAutoAddBoardId, selectAutoAssignBoardOnClick, @@ -121,13 +121,14 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { {(ref) => ( } - openDelay={1000} + openDelay={500} placement="left" closeOnScroll > diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index 32cef15838d..61bb03446c3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -4,7 +4,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIDroppable from 'common/components/IAIDroppable'; import type { RemoveFromBoardDropData } from 'features/dnd/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; -import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip'; +import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; import { selectAutoAddBoardId, @@ -58,13 +58,9 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { {(ref) => ( + } - openDelay={1000} + openDelay={500} placement="left" closeOnScroll > From 1a6d80e82942363a7a93ac9f7e93dba86796e3bc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Sep 2024 19:39:22 +1000 Subject: [PATCH 6/6] feat(ui): remove openDelay on board tooltip Now that the counts are already available and the tooltip does not make a network request, we can remove the delay (which was added to prevent network thrashing as you moved the mouse over the boards list). --- .../gallery/components/Boards/BoardsList/GalleryBoard.tsx | 1 - .../gallery/components/Boards/BoardsList/NoBoardBoard.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 9ae3d923544..75ea1410214 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -128,7 +128,6 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { coverImageName={board.cover_image_name} /> } - openDelay={500} placement="left" closeOnScroll > diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index 61bb03446c3..41d90a30c95 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -60,7 +60,6 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { label={ } - openDelay={500} placement="left" closeOnScroll >