diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py
index a2ac6b45c8..d224242a56 100644
--- a/invokeai/app/api/routers/images.py
+++ b/invokeai/app/api/routers/images.py
@@ -1,7 +1,7 @@
import io
import json
import traceback
-from typing import ClassVar, Optional
+from typing import ClassVar, Literal, Optional
from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
@@ -562,3 +562,63 @@ async def get_bulk_download_item(
return response
except Exception:
raise HTTPException(status_code=404)
+
+
+@images_router.get("/collections/counts", operation_id="get_image_collection_counts")
+async def get_image_collection_counts(
+ image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to count."),
+ categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
+ is_intermediate: Optional[bool] = Query(default=None, description="Whether to include intermediate images."),
+ board_id: Optional[str] = Query(
+ default=None,
+ description="The board id to filter by. Use 'none' to find images without a board.",
+ ),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
+) -> dict[str, int]:
+ """Gets counts for starred and unstarred image collections"""
+
+ try:
+ counts = ApiDependencies.invoker.services.images.get_collection_counts(
+ image_origin=image_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ )
+ return counts
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get collection counts")
+
+
+@images_router.get("/collections/{collection}", operation_id="get_image_collection")
+async def get_image_collection(
+ collection: Literal["starred", "unstarred"] = Path(..., description="The collection to retrieve from"),
+ 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 offset within the collection"),
+ limit: int = Query(default=50, description="The number of images to return"),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
+) -> OffsetPaginatedResults[ImageDTO]:
+ """Gets images from a specific collection (starred or unstarred)"""
+
+ try:
+ image_dtos = ApiDependencies.invoker.services.images.get_collection_images(
+ collection=collection,
+ offset=offset,
+ limit=limit,
+ order_dir=order_dir,
+ image_origin=image_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ )
+ return image_dtos
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get collection images")
diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py
index 1211c9762c..de42fa419d 100644
--- a/invokeai/app/services/image_records/image_records_base.py
+++ b/invokeai/app/services/image_records/image_records_base.py
@@ -1,6 +1,6 @@
from abc import ABC, abstractmethod
from datetime import datetime
-from typing import Optional
+from typing import Literal, Optional
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.image_records.image_records_common import (
@@ -97,3 +97,31 @@ class ImageRecordStorageBase(ABC):
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
"""Gets the most recent image for a board."""
pass
+
+ @abstractmethod
+ def get_collection_counts(
+ self,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> dict[str, int]:
+ """Gets counts for starred and unstarred image collections."""
+ pass
+
+ @abstractmethod
+ def get_collection_images(
+ self,
+ collection: Literal["starred", "unstarred"],
+ offset: int = 0,
+ limit: int = 10,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> OffsetPaginatedResults[ImageRecord]:
+ """Gets images from a specific collection (starred or unstarred)."""
+ pass
diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py
index 23674e14e6..592004b793 100644
--- a/invokeai/app/services/image_records/image_records_sqlite.py
+++ b/invokeai/app/services/image_records/image_records_sqlite.py
@@ -1,6 +1,6 @@
import sqlite3
from datetime import datetime
-from typing import Optional, Union, cast
+from typing import Literal, Optional, Union, cast
from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator
from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase
@@ -386,3 +386,181 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
return None
return deserialize_image_record(dict(result))
+
+ def get_collection_counts(
+ self,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> dict[str, int]:
+ cursor = self._conn.cursor()
+
+ # Build the base query conditions (same as get_many)
+ base_query = """--sql
+ FROM images
+ LEFT JOIN board_images ON board_images.image_name = images.image_name
+ WHERE 1=1
+ """
+
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ if image_origin is not None:
+ query_conditions += """--sql
+ AND images.image_origin = ?
+ """
+ query_params.append(image_origin.value)
+
+ if categories is not None:
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ query_conditions += f"""--sql
+ AND images.image_category IN ( {placeholders} )
+ """
+ for c in category_strings:
+ query_params.append(c)
+
+ if is_intermediate is not None:
+ query_conditions += """--sql
+ AND images.is_intermediate = ?
+ """
+ query_params.append(is_intermediate)
+
+ if board_id == "none":
+ query_conditions += """--sql
+ AND board_images.board_id IS NULL
+ """
+ elif board_id is not None:
+ query_conditions += """--sql
+ AND board_images.board_id = ?
+ """
+ query_params.append(board_id)
+
+ if search_term:
+ query_conditions += """--sql
+ AND (
+ images.metadata LIKE ?
+ OR images.created_at LIKE ?
+ )
+ """
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ # Get starred count
+ starred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = TRUE;"
+ cursor.execute(starred_query, query_params)
+ starred_count = cast(int, cursor.fetchone()[0])
+
+ # Get unstarred count
+ unstarred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = FALSE;"
+ cursor.execute(unstarred_query, query_params)
+ unstarred_count = cast(int, cursor.fetchone()[0])
+
+ return {
+ "starred_count": starred_count,
+ "unstarred_count": unstarred_count,
+ "total_count": starred_count + unstarred_count,
+ }
+
+ def get_collection_images(
+ self,
+ collection: Literal["starred", "unstarred"],
+ offset: int = 0,
+ limit: int = 10,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> OffsetPaginatedResults[ImageRecord]:
+ cursor = self._conn.cursor()
+
+ # Base queries
+ count_query = """--sql
+ SELECT COUNT(*)
+ FROM images
+ LEFT JOIN board_images ON board_images.image_name = images.image_name
+ WHERE 1=1
+ """
+
+ images_query = f"""--sql
+ SELECT {IMAGE_DTO_COLS}
+ FROM images
+ LEFT JOIN board_images ON board_images.image_name = images.image_name
+ WHERE 1=1
+ """
+
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ # Add starred/unstarred filter
+ is_starred = collection == "starred"
+ query_conditions += """--sql
+ AND images.starred = ?
+ """
+ query_params.append(is_starred)
+
+ if image_origin is not None:
+ query_conditions += """--sql
+ AND images.image_origin = ?
+ """
+ query_params.append(image_origin.value)
+
+ if categories is not None:
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ query_conditions += f"""--sql
+ AND images.image_category IN ( {placeholders} )
+ """
+ for c in category_strings:
+ query_params.append(c)
+
+ if is_intermediate is not None:
+ query_conditions += """--sql
+ AND images.is_intermediate = ?
+ """
+ query_params.append(is_intermediate)
+
+ if board_id == "none":
+ query_conditions += """--sql
+ AND board_images.board_id IS NULL
+ """
+ elif board_id is not None:
+ query_conditions += """--sql
+ AND board_images.board_id = ?
+ """
+ query_params.append(board_id)
+
+ if search_term:
+ query_conditions += """--sql
+ AND (
+ images.metadata LIKE ?
+ OR images.created_at LIKE ?
+ )
+ """
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ # Add ordering and pagination
+ query_pagination = f"""--sql
+ ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ?
+ """
+
+ # Execute images query
+ images_query += query_conditions + query_pagination + ";"
+ images_params = query_params.copy()
+ images_params.extend([limit, offset])
+
+ cursor.execute(images_query, images_params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ images = [deserialize_image_record(dict(r)) for r in result]
+
+ # Execute count query
+ count_query += query_conditions + ";"
+ cursor.execute(count_query, query_params)
+ count = cast(int, cursor.fetchone()[0])
+
+ return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py
index 5328c1854e..dd998e2578 100644
--- a/invokeai/app/services/images/images_base.py
+++ b/invokeai/app/services/images/images_base.py
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
-from typing import Callable, Optional
+from typing import Callable, Literal, Optional
from PIL.Image import Image as PILImageType
@@ -147,3 +147,31 @@ class ImageServiceABC(ABC):
def delete_images_on_board(self, board_id: str):
"""Deletes all images on a board."""
pass
+
+ @abstractmethod
+ def get_collection_counts(
+ self,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> dict[str, int]:
+ """Gets counts for starred and unstarred image collections."""
+ pass
+
+ @abstractmethod
+ def get_collection_images(
+ self,
+ collection: Literal["starred", "unstarred"],
+ offset: int = 0,
+ limit: int = 10,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> OffsetPaginatedResults[ImageDTO]:
+ """Gets images from a specific collection (starred or unstarred)."""
+ pass
diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py
index 1489a7ce45..83809ad3f4 100644
--- a/invokeai/app/services/images/images_default.py
+++ b/invokeai/app/services/images/images_default.py
@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import Literal, Optional
from PIL.Image import Image as PILImageType
@@ -309,3 +309,68 @@ class ImageService(ImageServiceABC):
except Exception as e:
self.__invoker.services.logger.error("Problem getting intermediates count")
raise e
+
+ def get_collection_counts(
+ self,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> dict[str, int]:
+ try:
+ return self.__invoker.services.image_records.get_collection_counts(
+ image_origin=image_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting collection counts")
+ raise e
+
+ def get_collection_images(
+ self,
+ collection: Literal["starred", "unstarred"],
+ offset: int = 0,
+ limit: int = 10,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ ) -> OffsetPaginatedResults[ImageDTO]:
+ try:
+ results = self.__invoker.services.image_records.get_collection_images(
+ collection=collection,
+ offset=offset,
+ limit=limit,
+ order_dir=order_dir,
+ image_origin=image_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ )
+
+ image_dtos = [
+ image_record_to_dto(
+ 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),
+ )
+ for r in results.items
+ ]
+
+ return OffsetPaginatedResults[ImageDTO](
+ items=image_dtos,
+ offset=results.offset,
+ limit=results.limit,
+ total=results.total,
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting collection images")
+ raise e
diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js
index 3e6498af4c..f4af658ea2 100644
--- a/invokeai/frontend/web/.eslintrc.js
+++ b/invokeai/frontend/web/.eslintrc.js
@@ -12,11 +12,13 @@ module.exports = {
// TODO: ENABLE THIS RULE BEFORE v6.0.0
// 'i18next/no-literal-string': 'error',
// https://eslint.org/docs/latest/rules/no-console
- 'no-console': 'error',
+ 'no-console': 'warn',
// https://eslint.org/docs/latest/rules/no-promise-executor-return
'no-promise-executor-return': 'error',
// https://eslint.org/docs/latest/rules/require-await
'require-await': 'error',
+ // TODO: ENABLE THIS RULE BEFORE v6.0.0
+ 'react/display-name': 'off',
'no-restricted-properties': [
'error',
{
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index 9397144751..ec757494f5 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -39,7 +39,6 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware';
import type { JsonObject } from 'type-fest';
import { STORAGE_PREFIX } from './constants';
-import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware';
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
@@ -177,7 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
.concat(api.middleware)
.concat(dynamicMiddlewares)
.concat(authToastMiddleware)
- .concat(getDebugLoggerMiddleware())
+ // .concat(getDebugLoggerMiddleware())
.prepend(listenerMiddleware.middleware),
enhancers: (getDefaultEnhancers) => {
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());
diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx
index 1679cc1fb1..ec8f15f72e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx
@@ -25,9 +25,8 @@ import { useBoardName } from 'services/api/hooks/useBoardName';
import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover';
import { GalleryUploadButton } from './GalleryUploadButton';
-import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
-import { GalleryPagination } from './ImageGrid/GalleryPagination';
import { GallerySearch } from './ImageGrid/GallerySearch';
+import { NewGallery } from './NewGallery';
const BASE_STYLES: ChakraProps['sx'] = {
fontWeight: 'semibold',
@@ -112,8 +111,9 @@ export const GalleryPanel = memo(() => {
/>
-
-
+ {/*
+ */}
+
);
});
diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx
new file mode 100644
index 0000000000..9f6807d278
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx
@@ -0,0 +1,340 @@
+import { Box, Flex, forwardRef, Grid, GridItem, Image, Skeleton, Spinner, Text } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors';
+import React, { memo, useCallback, useMemo, useState } from 'react';
+import { VirtuosoGrid } from 'react-virtuoso';
+import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery } from 'services/api/endpoints/images';
+import type { ImageDTO } from 'services/api/types';
+
+// Placeholder image component for now
+const ImagePlaceholder = memo(({ image }: { image: ImageDTO }) => (
+
+));
+
+ImagePlaceholder.displayName = 'ImagePlaceholder';
+
+// Loading skeleton component
+const ImageSkeleton = memo(() => );
+
+ImageSkeleton.displayName = 'ImageSkeleton';
+
+// Hook to manage image data for virtual scrolling
+const useVirtualImageData = () => {
+ const queryArgs = useAppSelector(selectImageCollectionQueryArgs);
+
+ // Get total counts for position mapping
+ const { data: counts, isLoading: countsLoading } = useGetImageCollectionCountsQuery(queryArgs);
+
+ // Cache for loaded image ranges
+ const [loadedRanges, setLoadedRanges] = useState