diff --git a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx index cf5b666bb01..ca640343b06 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx @@ -102,13 +102,15 @@ When image indexing is turned on, the Gallery can also search by what media *sho * **Search by text.** Click the sparkle button at the right end of the search field (**Search semantically**). The field changes to **Describe what to find…**; describe what you are looking for. Any text already in the field is used straight away. * **Search by image.** Drag an image onto the search field: a thumbnail from the Gallery, an image from the Preview, an image file from your computer, or an image from another browser tab. The Gallery then shows the media most similar to it, labelled **Similar to …**. -Results are ranked by similarity, most similar first, up to 500 items from the selected board. Videos are included. The Media and Uploads tabs, the sort order, the starred filter and date tokens do not apply to ranked results. +Results are ranked by similarity, most similar first, up to 500 items from the selected board. Videos are included unless the media type filter hides them. The Media and Uploads tabs, the sort order, the starred filter and date tokens do not apply to ranked results. To leave semantic search, click the **✕** in the search field, click the sparkle button again (**Exit semantic search**), or switch tabs. If the index model is not installed, the Gallery says so when you try to search. Archived boards are not indexed. -### Sorting and the starred filter +### Sorting and filtering -The sort menu orders media by creation time, **Newest** first (the default) or **Oldest** first. The star button next to it (**Starred only**) shows only starred items. Images and videos are always shown together. +The sort menu orders media by creation time, **Newest** first (the default) or **Oldest** first. The star button (**Starred only**) shows only starred items. + +The media type button between them chooses which kinds of media the Gallery shows: **Both** (the default), **Images only**, or **Videos only**. Its icon shows the current choice, and it is highlighted while it hides one kind. The choice is saved with the project and also applies to the [Image Map](/users-guide/image-map/introduction/). If a board has nothing of the chosen kind, the Gallery says so and offers **Show all media**. Revealing an item the filter hides, for example with **Find in Gallery** from the Preview, switches the filter back to **Both**. The Gallery picker that chooses media for other features always lists both kinds. ### Browsing large boards diff --git a/docs/src/content/docs/Users Guide/09.Image Map/01.introduction.mdx b/docs/src/content/docs/Users Guide/09.Image Map/01.introduction.mdx index 2b9577e9523..1232a3615e9 100644 --- a/docs/src/content/docs/Users Guide/09.Image Map/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/09.Image Map/01.introduction.mdx @@ -81,6 +81,14 @@ you navigate the gallery. You can show and hide the tags by clicking on the "label" icon in the upper right-hand of the panel. +### Showing only images or only videos + +The media type button, just left of the settings icon in the panel's +title bar, chooses whether the map plots **Both** images and videos, +**Images only**, or **Videos only**. It shares its setting with the +media type filter in the gallery, so changing it in either place changes +both. Clusters and their tags are recomputed for the media shown. + ### Floating the Image Map Window When you open the Image Map in the right rail, a floating window icon diff --git a/invokeai/app/api/routers/gallery.py b/invokeai/app/api/routers/gallery.py index 2f30d1eef2d..e1b655a7c3c 100644 --- a/invokeai/app/api/routers/gallery.py +++ b/invokeai/app/api/routers/gallery.py @@ -7,6 +7,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.images import _assert_board_read_access +from invokeai.app.services.gallery.gallery_base import GalleryMediaKind from invokeai.app.services.gallery.gallery_common import ( GalleryItem, GalleryItemKind, @@ -19,6 +20,12 @@ gallery_router = APIRouter(prefix="/v1/gallery", tags=["gallery"]) +MediaKindQuery = Query( + default=None, + description="List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and " + "names cover only the listed kind.", +) + @gallery_router.get( "/items/", @@ -48,6 +55,7 @@ def list_gallery_items( description="Filter by starred state: true for starred items only, false for unstarred only. Omit to include both.", ), search_term: Optional[str] = Query(default=None, description="The term to search for"), + media_kind: Optional[GalleryMediaKind] = MediaKindQuery, created_from: Optional[date] = Query( default=None, description="Inclusive start date (YYYY-MM-DD) to filter by created_at." ), @@ -74,6 +82,7 @@ def list_gallery_items( created_from=created_from.isoformat() if created_from else None, created_to=created_to.isoformat() if created_to else None, starred=starred, + media_kind=media_kind, ) @@ -102,6 +111,7 @@ def get_gallery_item_location( description="Filter by starred state: true for starred items only, false for unstarred only. Omit to include both.", ), search_term: Optional[str] = Query(default=None, description="The term to search for"), + media_kind: Optional[GalleryMediaKind] = MediaKindQuery, created_from: Optional[date] = Query( default=None, description="Inclusive start date (YYYY-MM-DD) to filter by created_at." ), @@ -127,6 +137,7 @@ def get_gallery_item_location( created_from=created_from.isoformat() if created_from else None, created_to=created_to.isoformat() if created_to else None, starred=starred, + media_kind=media_kind, ) if location is None: raise HTTPException(status_code=404, detail="Gallery item not found") @@ -167,6 +178,7 @@ def list_gallery_item_names( description="Filter by starred state: true for starred items only, false for unstarred only. Omit to include both.", ), search_term: Optional[str] = Query(default=None, description="The term to search for"), + media_kind: Optional[GalleryMediaKind] = MediaKindQuery, ) -> GalleryItemNames: """Returns the ordered flat list of item names — used to drive virtualized gallery selection. @@ -191,6 +203,7 @@ def list_gallery_item_names( created_from=created_from.isoformat() if created_from else None, created_to=created_to.isoformat() if created_to else None, starred=starred, + media_kind=media_kind, ) except Exception: raise HTTPException(status_code=500, detail="Failed to get gallery item names") @@ -221,6 +234,7 @@ def get_gallery_item_names( description="Filter by starred state: true for starred items only, false for unstarred only. Omit to include both.", ), search_term: Optional[str] = Query(default=None, description="The term to search for"), + media_kind: Optional[GalleryMediaKind] = MediaKindQuery, created_from: Optional[date] = Query( default=None, description="Inclusive start date (YYYY-MM-DD) to filter by created_at." ), @@ -251,6 +265,7 @@ def get_gallery_item_names( created_from=created_from.isoformat() if created_from else None, created_to=created_to.isoformat() if created_to else None, starred=starred, + media_kind=media_kind, ) except Exception: raise HTTPException(status_code=500, detail="Failed to get gallery item names") diff --git a/invokeai/app/api/routers/image_map.py b/invokeai/app/api/routers/image_map.py index 6f205a285a8..d1745d2df5f 100644 --- a/invokeai/app/api/routers/image_map.py +++ b/invokeai/app/api/routers/image_map.py @@ -331,7 +331,16 @@ def _with_model(indexer: ImageIndexServiceBase, model_id: str, operation: Callab ) -def _served_kinds(include_videos: bool) -> tuple[MediaKind, ...]: +MediaKindQuery = Query( + default=None, + description="Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, " + "overriding include_videos; omit to serve what include_videos selects.", +) + + +def _served_kinds(include_videos: bool, media_kind: Optional[MediaKind]) -> tuple[MediaKind, ...]: + if media_kind is not None: + return (media_kind,) return ("image", "video") if include_videos else ("image",) @@ -409,6 +418,7 @@ async def get_image_map_points( eps: Optional[float] = ClusterEpsQuery, min_samples: int = ClusterMinSamplesQuery, include_videos: bool = IncludeVideosQuery, + media_kind: Optional[MediaKind] = MediaKindQuery, ) -> ImageMapPointsResponse: """Gets the current user's semantic image map. @@ -433,7 +443,7 @@ async def get_image_map_points( ) user_id, is_admin = _scope(current_user) - kinds = _served_kinds(include_videos) + kinds = _served_kinds(include_videos, media_kind) # Both reads are database work (on SQLite under the process-wide lock), and on a large gallery # the accessible listing is the expensive one — so they run off the event loop, like the # clustering below. @@ -603,6 +613,7 @@ async def search_image_map( video_name: Optional[str] = Query(default=None, description="Reference video for similarity search"), limit: int = Query(default=100, ge=1, le=500, description="Maximum number of results"), include_videos: bool = IncludeVideosQuery, + media_kind: Optional[MediaKind] = MediaKindQuery, board_id: Optional[str] = SearchBoardQuery, created_date: Optional[date] = SearchCreatedDateQuery, ) -> ImageMapSearchResponse: @@ -700,7 +711,7 @@ def embed_from_file() -> np.ndarray: scope_user, query_embedding, limit, - _served_kinds(include_videos), + _served_kinds(include_videos, media_kind), within, ) return ImageMapSearchResponse( @@ -833,6 +844,7 @@ async def search_image_map_by_image( image_url: Optional[str] = Query(default=None, max_length=2000, description="URL of a reference image"), limit: int = Query(default=100, ge=1, le=500, description="Maximum number of results"), include_videos: bool = IncludeVideosQuery, + media_kind: Optional[MediaKind] = MediaKindQuery, board_id: Optional[str] = SearchBoardQuery, created_date: Optional[date] = SearchCreatedDateQuery, ) -> ImageMapSearchResponse: @@ -900,7 +912,7 @@ def embed_bytes() -> np.ndarray: scope_user, query_embedding, limit, - _served_kinds(include_videos), + _served_kinds(include_videos, media_kind), within, ) return ImageMapSearchResponse( @@ -940,6 +952,7 @@ async def get_image_map_cluster_labels( min_samples: int = ClusterMinSamplesQuery, top_k: int = Query(default=3, ge=1, le=10, description="Candidate labels per cluster"), include_videos: bool = IncludeVideosQuery, + media_kind: Optional[MediaKind] = MediaKindQuery, ) -> ImageMapClusterLabelsResponse: """Labels the user's visible clusters with the most similar vocabulary phrases. @@ -977,7 +990,7 @@ def build() -> tuple[dict[int, dict], str]: accessible = set(current_items) # Exactly the mask /points applies, kind filter included: the two responses' visible # hashes are compared by the client, so they must cover the same set. - kinds = _served_kinds(include_videos) + kinds = _served_kinds(include_videos, media_kind) mask = np.fromiter( (item in accessible and item.kind in kinds for item in record.items), dtype=bool, @@ -1251,7 +1264,9 @@ def update_image_map_vocab(update: ImageMapVocabUpdate, current_user: AdminUserO @image_map_router.get("/status", operation_id="get_image_map_status", response_model=ImageMapStatusResponse) def get_image_map_status( - current_user: CurrentUserOrDefault, include_videos: bool = IncludeVideosQuery + current_user: CurrentUserOrDefault, + include_videos: bool = IncludeVideosQuery, + media_kind: Optional[MediaKind] = MediaKindQuery, ) -> ImageMapStatusResponse: """Gets embedding index progress and the user's projection cache status.""" services = ApiDependencies.invoker.services @@ -1275,7 +1290,7 @@ def get_image_map_status( # Count only currently-accessible points; a stale record's raw count # would reveal the size of a since-revoked scope. Kinds the caller did not ask for are # excluded too, so this count matches the points /points would serve it. - kinds = _served_kinds(include_videos) + kinds = _served_kinds(include_videos, media_kind) visible_count = len({item for item in set(record.items) & set(current_items) if item.kind in kinds}) projection = ImageMapProjectionStatus( state="ready" if visible_count else "empty", diff --git a/invokeai/app/api/routers/virtual_boards.py b/invokeai/app/api/routers/virtual_boards.py index 8274d5b1a2e..42f7bf2e29d 100644 --- a/invokeai/app/api/routers/virtual_boards.py +++ b/invokeai/app/api/routers/virtual_boards.py @@ -3,6 +3,8 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.gallery import MediaKindQuery +from invokeai.app.services.gallery.gallery_base import GalleryMediaKind from invokeai.app.services.gallery.gallery_common import GalleryItemNamesResult from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageNamesResult from invokeai.app.services.shared.pagination import SQLiteDirection @@ -79,6 +81,7 @@ def list_virtual_board_item_names_by_date( order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The sort direction"), categories: list[ImageCategory] | None = Query(default=None, description="The categories of items to include"), search_term: str | None = Query(default=None, description="Search term to filter items"), + media_kind: GalleryMediaKind | None = MediaKindQuery, ) -> GalleryItemNamesResult: """Gets ordered polymorphic (image + video) item refs for a specific date. @@ -96,6 +99,7 @@ def list_virtual_board_item_names_by_date( is_admin=current_user.is_admin, created_date=date, starred=starred, + media_kind=media_kind, ) except Exception: raise HTTPException(status_code=500, detail="Failed to get gallery item names for date") diff --git a/invokeai/app/services/gallery/gallery_base.py b/invokeai/app/services/gallery/gallery_base.py index b82e71deeed..a98a607a452 100644 --- a/invokeai/app/services/gallery/gallery_base.py +++ b/invokeai/app/services/gallery/gallery_base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Optional +from typing import Literal, Optional from invokeai.app.services.gallery.gallery_common import ( BoardMediaSummary, @@ -13,6 +13,9 @@ from invokeai.app.services.shared.pagination import OffsetPaginatedResults, SQLiteDirection from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO +# A listing's media filter: one kind's items only. None (the default everywhere) lists both. +GalleryMediaKind = Literal["image", "video"] + class GalleryServiceABC(ABC): """High-level service producing a polymorphic stream of images and videos.""" @@ -34,6 +37,7 @@ def list_items( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> OffsetPaginatedResults[GalleryItem]: """Lists a paginated, time-sorted stream of image + video items.""" pass @@ -54,6 +58,7 @@ def get_item_location( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> Optional[GalleryItemLocation]: """Finds an item's exact zero-based position in an ordinary item listing.""" pass @@ -74,6 +79,7 @@ def list_item_names( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> GalleryItemNamesResult: """Returns ordered (kind, name) refs for optimistic UI / virtualized lists. @@ -98,6 +104,7 @@ def get_item_names( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> GalleryItemNames: """Returns the ordered flat name list for optimistic UI / virtualized lists. @@ -107,6 +114,8 @@ def get_item_names( `created_from` and `created_to` are inclusive YYYY-MM-DD bounds on created_at (UTC days). `starred` restricts to starred (`True`) or unstarred (`False`) items; `None` applies no filter. + + `media_kind` restricts to images or videos; `None` lists both. """ pass diff --git a/invokeai/app/services/gallery/gallery_default.py b/invokeai/app/services/gallery/gallery_default.py index 9ed9d20fdc8..77311095063 100644 --- a/invokeai/app/services/gallery/gallery_default.py +++ b/invokeai/app/services/gallery/gallery_default.py @@ -1,6 +1,6 @@ from typing import Any, Optional, Sequence -from invokeai.app.services.gallery.gallery_base import GalleryServiceABC +from invokeai.app.services.gallery.gallery_base import GalleryMediaKind, GalleryServiceABC from invokeai.app.services.gallery.gallery_common import ( BoardMediaSummary, GalleryItem, @@ -47,6 +47,7 @@ def list_items( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> OffsetPaginatedResults[GalleryItem]: filters = Filters( origin=origin, @@ -59,6 +60,7 @@ def list_items( created_from=created_from, created_to=created_to, starred=starred, + media_kind=media_kind, ) rows, total = self._queries.gallery.page( filters, @@ -85,6 +87,7 @@ def get_item_location( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> Optional[GalleryItemLocation]: """Finds item's exact rank without building or returning the full name list.""" filters = Filters( @@ -98,6 +101,7 @@ def get_item_location( created_from=created_from, created_to=created_to, starred=starred, + media_kind=media_kind, ) row = self._queries.gallery.location( filters, kind=kind.value, name=name, descending=order_dir == SQLiteDirection.Descending @@ -132,6 +136,7 @@ def list_item_names( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> GalleryItemNamesResult: filters = Filters( origin=origin, @@ -145,6 +150,7 @@ def list_item_names( created_from=created_from, created_to=created_to, starred=starred, + media_kind=media_kind, ) rows, starred_count = self._names(filters, starred_first, order_dir) refs = [GalleryItemRef(kind=GalleryItemKind(kind), name=name) for kind, name, _ in rows] @@ -165,6 +171,7 @@ def get_item_names( created_from: Optional[str] = None, created_to: Optional[str] = None, starred: Optional[bool] = None, + media_kind: Optional[GalleryMediaKind] = None, ) -> GalleryItemNames: filters = Filters( origin=origin, @@ -178,6 +185,7 @@ def get_item_names( created_from=created_from, created_to=created_to, starred=starred, + media_kind=media_kind, ) rows, starred_count = self._names(filters, starred_first, order_dir) # The raw names, deliberately: building one model per row is what made the deprecated variant expensive. diff --git a/invokeai/app/services/shared/database/queries/gallery.py b/invokeai/app/services/shared/database/queries/gallery.py index c29e039ab5d..dec8eabe06e 100644 --- a/invokeai/app/services/shared/database/queries/gallery.py +++ b/invokeai/app/services/shared/database/queries/gallery.py @@ -1,4 +1,5 @@ """The gallery: images and videos listed as one, through a UNION ALL of two halves with the same columns. +A listing filtered to one kind reads only that kind's half. The filters apply alike to each half. A literal `kind` tells the halves apart, and duration and fps are NULL for images. Lists break ties by kind and name in the listing's direction, so that items of one moment keep one order @@ -7,8 +8,9 @@ import functools import itertools +import operator from collections.abc import Sequence -from typing import Any, NamedTuple, Optional +from typing import Any, Literal, NamedTuple, Optional from sqlalchemy import ( ColumnElement, @@ -67,6 +69,8 @@ class _Half(NamedTuple): class _Shape(NamedTuple): """Which filters a listing has: never their values, which are bound, so that the shapes are few.""" + # The halves the listing reads, in `_HALVES` order: both, or one kind's. + kinds: tuple[str, ...] origin: bool # The categories' values, sorted; None for no category filter. categories: Optional[tuple[str, ...]] @@ -96,9 +100,12 @@ class Filters(NamedTuple): created_from: Optional[str] = None created_to: Optional[str] = None starred: Optional[bool] = None + # One kind's items only; None for both. + media_kind: Optional[Literal["image", "video"]] = None def shape(self) -> _Shape: return _Shape( + kinds=tuple(_HALVES) if self.media_kind is None else (self.media_kind,), origin=self.origin is not None, categories=None if self.categories is None @@ -207,6 +214,11 @@ def _half(kind: str, shape: _Shape, names_only: bool) -> Select[Any]: return select(*columns).select_from(source).where(*_conditions(kind, shape)) +def _items(shape: _Shape, names_only: bool) -> Subquery: + """The included halves as one subquery. (A union of one half compiles to that half alone, without UNION.)""" + return union_all(*(_half(kind, shape, names_only) for kind in shape.kinds)).subquery("items") + + def _ordering(items: Subquery, starred_first: bool, descending: bool) -> list[ColumnElement[Any]]: keys = [items.c.created_at, items.c.kind, items.c.name] ordering = [key.desc() if descending else key.asc() for key in keys] @@ -217,7 +229,7 @@ def _ordering(items: Subquery, starred_first: bool, descending: bool) -> list[Co def _page(shape: _Shape, starred_first: bool, descending: bool) -> Select[Any]: """A page of items. `media_origin` is joined onto the chosen page rather than selected inside the halves: it is read out of `videos.metadata`, and selecting it there would read and parse every video before LIMIT.""" - items = union_all(_half("image", shape, False), _half("video", shape, False)).subquery("items") + items = _items(shape, False) page = ( select(items) .order_by(*_ordering(items, starred_first, descending)) @@ -235,19 +247,19 @@ def _page(shape: _Shape, starred_first: bool, descending: bool) -> Select[Any]: @functools.lru_cache(maxsize=256) def _count(shape: _Shape) -> Select[Any]: - """The items of both halves that match, in one statement.""" + """The items of the included halves that match, in one statement.""" counts = [] - for kind in _HALVES: + for kind in shape.kinds: # Without the board id a listing shows: a membership join cannot change the count, and MySQL does not # eliminate it. source, _ = _from(kind, shape, with_board=False) counts.append(select(func.count()).select_from(source).where(*_conditions(kind, shape)).scalar_subquery()) - return select(counts[0] + counts[1]) + return select(functools.reduce(operator.add, counts)) @functools.lru_cache(maxsize=256) def _names(shape: _Shape, starred_first: bool, descending: bool) -> Select[Any]: - items = union_all(_half("image", shape, True), _half("video", shape, True)).subquery("items") + items = _items(shape, True) return select(items.c.kind, items.c.name, items.c.starred).order_by(*_ordering(items, starred_first, descending)) @@ -362,7 +374,7 @@ class BoardSummary(NamedTuple): def _location(shape: _Shape, descending: bool) -> Select[Any]: """An item's zero-based index in the listing `_names` orders (starred items not first), and how many items match: ranked in the database, so no list of names is built. (Bind names other than the filters' own.)""" - items = union_all(_half("image", shape, True), _half("video", shape, True)).subquery("items") + items = _items(shape, True) ranked = select( items.c.kind, items.c.name, diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index b59b1243a55..0a4a9883124 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -7268,6 +7268,25 @@ "title": "Include Videos" }, "description": "Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names." + }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects.", + "title": "Media Kind" + }, + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects." } ], "responses": { @@ -7387,6 +7406,25 @@ }, "description": "Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names." }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects.", + "title": "Media Kind" + }, + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects." + }, { "name": "board_id", "in": "query", @@ -7506,6 +7544,25 @@ }, "description": "Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names." }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects.", + "title": "Media Kind" + }, + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects." + }, { "name": "board_id", "in": "query", @@ -7648,6 +7705,25 @@ "title": "Include Videos" }, "description": "Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names." + }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects.", + "title": "Media Kind" + }, + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects." } ], "responses": { @@ -7865,6 +7941,25 @@ "title": "Include Videos" }, "description": "Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names." + }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects.", + "title": "Media Kind" + }, + "description": "Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects." } ], "responses": { @@ -9717,6 +9812,25 @@ }, "description": "The term to search for" }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind.", + "title": "Media Kind" + }, + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind." + }, { "name": "created_from", "in": "query", @@ -9935,6 +10049,25 @@ }, "description": "The term to search for" }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind.", + "title": "Media Kind" + }, + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind." + }, { "name": "created_from", "in": "query", @@ -10199,6 +10332,25 @@ "title": "Search Term" }, "description": "The term to search for" + }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind.", + "title": "Media Kind" + }, + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind." } ], "responses": { @@ -10372,6 +10524,25 @@ }, "description": "The term to search for" }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind.", + "title": "Media Kind" + }, + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind." + }, { "name": "created_from", "in": "query", @@ -11557,6 +11728,25 @@ "title": "Search Term" }, "description": "Search term to filter items" + }, + { + "name": "media_kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": ["image", "video"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind.", + "title": "Media Kind" + }, + "description": "List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind." } ], "responses": { diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index 3eb521cd304..9348ce1f7b5 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -56493,6 +56493,8 @@ export interface operations { min_samples?: number; /** @description Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names. */ include_videos?: boolean; + /** @description Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects. */ + media_kind?: ("image" | "video") | null; }; header?: never; path?: never; @@ -56533,6 +56535,8 @@ export interface operations { limit?: number; /** @description Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names. */ include_videos?: boolean; + /** @description Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects. */ + media_kind?: ("image" | "video") | null; /** @description Restrict results to this board's items; 'none' selects items on no board. Omit to rank every accessible item. */ board_id?: string | null; /** @description Restrict results to items created on this ISO date, as the date-based virtual boards list them. */ @@ -56573,6 +56577,8 @@ export interface operations { limit?: number; /** @description Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names. */ include_videos?: boolean; + /** @description Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects. */ + media_kind?: ("image" | "video") | null; /** @description Restrict results to this board's items; 'none' selects items on no board. Omit to rank every accessible item. */ board_id?: string | null; /** @description Restrict results to items created on this ISO date, as the date-based virtual boards list them. */ @@ -56619,6 +56625,8 @@ export interface operations { top_k?: number; /** @description Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names. */ include_videos?: boolean; + /** @description Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects. */ + media_kind?: ("image" | "video") | null; }; header?: never; path?: never; @@ -56760,6 +56768,8 @@ export interface operations { query?: { /** @description Include indexed videos among the returned items. Leave off unless the client resolves each item through the endpoint its `kind` names. */ include_videos?: boolean; + /** @description Serve only images ('image') or only videos ('video'). When set it alone decides the served kinds, overriding include_videos; omit to serve what include_videos selects. */ + media_kind?: ("image" | "video") | null; }; header?: never; path?: never; @@ -57725,6 +57735,8 @@ export interface operations { starred?: boolean | null; /** @description The term to search for */ search_term?: string | null; + /** @description List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind. */ + media_kind?: ("image" | "video") | null; /** @description Inclusive start date (YYYY-MM-DD) to filter by created_at. */ created_from?: string | null; /** @description Inclusive end date (YYYY-MM-DD) to filter by created_at. */ @@ -57777,6 +57789,8 @@ export interface operations { starred?: boolean | null; /** @description The term to search for */ search_term?: string | null; + /** @description List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind. */ + media_kind?: ("image" | "video") | null; /** @description Inclusive start date (YYYY-MM-DD) to filter by created_at. */ created_from?: string | null; /** @description Inclusive end date (YYYY-MM-DD) to filter by created_at. */ @@ -57833,6 +57847,8 @@ export interface operations { starred?: boolean | null; /** @description The term to search for */ search_term?: string | null; + /** @description List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind. */ + media_kind?: ("image" | "video") | null; }; header?: never; path?: never; @@ -57879,6 +57895,8 @@ export interface operations { starred?: boolean | null; /** @description The term to search for */ search_term?: string | null; + /** @description List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind. */ + media_kind?: ("image" | "video") | null; /** @description Inclusive start date (YYYY-MM-DD) to filter by created_at. */ created_from?: string | null; /** @description Inclusive end date (YYYY-MM-DD) to filter by created_at. */ @@ -58604,6 +58622,8 @@ export interface operations { categories?: components["schemas"]["ImageCategory"][] | null; /** @description Search term to filter items */ search_term?: string | null; + /** @description List only images ('image') or only videos ('video'). Omit to include both. Totals, positions and names cover only the listed kind. */ + media_kind?: ("image" | "video") | null; }; header?: never; path: { diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 7188c71fc4a..bf72b3f1287 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -4528,6 +4528,15 @@ "starredOnly": "Starred only", "starredOnlyTooltip": "Show only starred items", "starredOnlyUnavailable": "The starred filter does not apply to similarity results", + "mediaFilter": "Media type", + "mediaFilterAll": "Both", + "mediaFilterImages": "Images only", + "mediaFilterVideos": "Videos only", + "mediaFilterTooltip": "Showing: {{option}}", + "mediaFilterTrigger": "Media type: {{option}}", + "noMatchingImages": "No images match this board, tab, or search.", + "noMatchingVideos": "No videos match this board, tab, or search.", + "showAllMedia": "Show all media", "starSelection": "Star selection", "statusChip_one": "Gallery: {{count}} item", "statusChip_other": "Gallery: {{count}} items", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index 5eeb92fef99..f7764bb6dcc 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -419,6 +419,52 @@ test('starred filter narrows gallery items, names, and date boards consistently' }); }); +test('media_kind narrows gallery items, names, location, date boards, and the image map consistently', async () => { + await withRepresentativeBackend(async (backend) => { + const query = 'categories=general&is_intermediate=false&starred_first=false&limit=1000&offset=0'; + const all = await getJson(backend, `/api/v1/gallery/items/?${query}`); + const videos = await getJson(backend, `/api/v1/gallery/items/?${query}&media_kind=video`); + const images = await getJson(backend, `/api/v1/gallery/items/?${query}&media_kind=image`); + + assert.ok(videos.total > 0, 'the fixture listing should contain videos'); + assert.ok(videos.items.every((item) => item.kind === 'video')); + assert.ok(images.items.every((item) => item.kind === 'image')); + assert.equal(videos.total + images.total, all.total); + + const names = await getJson( + backend, + '/api/v1/gallery/items/names?categories=general&is_intermediate=false&starred_first=false&media_kind=video' + ); + assert.equal(names.total_count, videos.total); + + const video = videos.items[0]; + const locationQuery = `categories=general&is_intermediate=false&starred_first=false&kind=video&name=${encodeURIComponent(video.name)}`; + const location = await getJson(backend, `/api/v1/gallery/items/location?${locationQuery}&media_kind=video`); + assert.deepEqual(location, { index: 0, kind: 'video', name: video.name, total: videos.total }); + const excluded = await fetch(`${backend.origin}/api/v1/gallery/items/location?${locationQuery}&media_kind=image`); + assert.equal(excluded.status, 404); + await excluded.body?.cancel(); + + const dated = await getJson( + backend, + '/api/v1/virtual_boards/by_date/2026-01-15/item_names?categories=general&is_intermediate=false&media_kind=video' + ); + assert.ok(dated.total_count > 0); + assert.ok(dated.items.every((item) => item.kind === 'video')); + + const points = await getJson(backend, '/api/v1/image_map/points?include_videos=true&media_kind=video'); + const labels = await getJson(backend, '/api/v1/image_map/cluster_labels?include_videos=true&media_kind=video'); + const bothPoints = await getJson(backend, '/api/v1/image_map/points?include_videos=true'); + const status = await getJson(backend, '/api/v1/image_map/status?include_videos=true&media_kind=video'); + + assert.ok(points.points.length > 0); + assert.ok(points.points.every((point) => point.kind === 'video')); + assert.equal(labels.visible_hash, points.visible_hash); + assert.notEqual(points.visible_hash, bothPoints.visible_hash); + assert.equal(status.projection.point_count, points.points.length); + }); +}); + test('video DTO, Details, poster, full media, HEAD, and Range routes use the checked-in assets', async () => { await withRepresentativeBackend(async (backend) => { const name = 'fixture-video-001.mp4'; diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index b1bffe1362b..281b8c8c77f 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -182,11 +182,29 @@ const timestamp = (state) => { /** Keep both map endpoints on one fixed projection timestamp; timestamp(state) advances on every call. */ const IMAGE_MAP_UPDATED_AT = '2026-01-01 00:00:00.000'; +/** The backend's `media_kind` query param: 'image' or 'video', or null (both) when absent or unknown. */ +const getRequestedMediaKind = (url) => { + const mediaKind = url.searchParams.get('media_kind'); + + return mediaKind === 'image' || mediaKind === 'video' ? mediaKind : null; +}; + +/** Image-map kinds, as the backend serves them: `media_kind` alone decides when set, else `include_videos`. */ +const getImageMapServedKinds = (url) => { + const mediaKind = getRequestedMediaKind(url); + + if (mediaKind) { + return [mediaKind]; + } + + return url.searchParams.get('include_videos') === 'true' ? ['image', 'video'] : ['image']; +}; + /** Use deterministic coordinates and round-robin cluster IDs; this fixture does not run clustering. */ -const imageMapPoints = (state, includeVideos) => { +const imageMapPoints = (state, kinds) => { const items = [ - ...[...state.images.keys()].map((name) => ({ kind: 'image', name })), - ...(includeVideos ? [...state.videos.keys()].map((name) => ({ kind: 'video', name })) : []), + ...(kinds.includes('image') ? [...state.images.keys()].map((name) => ({ kind: 'image', name })) : []), + ...(kinds.includes('video') ? [...state.videos.keys()].map((name) => ({ kind: 'video', name })) : []), ]; return items.map((item, index) => { @@ -410,12 +428,16 @@ const filterGalleryItems = (state, url, { createdDate, starredFirst } = {}) => { const createdFrom = url.searchParams.get('created_from'); const createdTo = url.searchParams.get('created_to'); const intermediate = getOptionalBoolean(url, 'is_intermediate'); + const mediaKind = getRequestedMediaKind(url); const origin = url.searchParams.get('origin'); const starred = getOptionalBoolean(url, 'starred'); const searchTerm = url.searchParams.get('search_term')?.trim().toLocaleLowerCase() ?? ''; return getGalleryCandidates(state) .filter(({ item, origin: itemOrigin, searchable }) => { + if (mediaKind && item.kind !== mediaKind) { + return false; + } if (boardId && (boardId === 'none' ? item.board_id !== null : item.board_id !== boardId)) { return false; } @@ -859,17 +881,17 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } if (method === 'GET' && path === '/api/v1/image_map/points') { - const includeVideos = url.searchParams.get('include_videos') === 'true'; - const points = imageMapPoints(state, includeVideos); + const kinds = getImageMapServedKinds(url); + const points = imageMapPoints(state, kinds); return json(200, { cluster_eps: 0.5, point_count: points.length, points, stale: false, - state: 'ready', + state: points.length > 0 ? 'ready' : 'empty', updated_at: IMAGE_MAP_UPDATED_AT, - visible_hash: `visible-${String(includeVideos)}`, + visible_hash: `visible-${kinds.join('+')}`, }); } @@ -878,7 +900,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { labels: { 0: { alternates: ['sunset', 'coastline'], label: 'beaches' } }, // Labels must match /points on both projection timestamp and visible set. updated_at: IMAGE_MAP_UPDATED_AT, - visible_hash: `visible-${String(url.searchParams.get('include_videos') === 'true')}`, + visible_hash: `visible-${getImageMapServedKinds(url).join('+')}`, }); } @@ -897,11 +919,17 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (method === 'GET' && path === '/api/v1/image_map/status') { const total = state.images.size + state.videos.size; + const pointCount = imageMapPoints(state, getImageMapServedKinds(url)).length; return json(200, { enabled: true, index: { embedded: total, failed: 0, total }, - projection: { point_count: total, stale: false, state: 'ready', updated_at: IMAGE_MAP_UPDATED_AT }, + projection: { + point_count: pointCount, + stale: false, + state: pointCount > 0 ? 'ready' : 'empty', + updated_at: IMAGE_MAP_UPDATED_AT, + }, }); } diff --git a/invokeai/frontend/webv2/src/features/gallery/README.md b/invokeai/frontend/webv2/src/features/gallery/README.md index 49359ef2c1d..22e1f60c6f2 100644 --- a/invokeai/frontend/webv2/src/features/gallery/README.md +++ b/invokeai/frontend/webv2/src/features/gallery/README.md @@ -2,7 +2,7 @@ Paths are relative to the webv2 `src/` directory. -Gallery ordinary listings are Query-owned sparse pages shared by grid, picker, Preview, and deep-reveal flows. Page key = account lifetime + canonical listing filters + aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. Cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by recent-item limit so an authoritative item does not reappear in overlay when its page is evicted. Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies item at returned absolute position, and retries that rank/page check at most once if offsets shifted. Persisted Preview selection keeps listing identity from when item was selected; changing active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations; its async navigation is fenced by captured account epoch so a late page cannot stamp selection into a newer account lifetime. +Gallery ordinary listings are Query-owned sparse pages shared by grid, picker, Preview, and deep-reveal flows. The Gallery widget's media kind (both, images, or videos) is part of the canonical listing filter, present only when restricted; the picker always lists both kinds. Page key = account lifetime + canonical listing filters + aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. Cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by recent-item limit so an authoritative item does not reappear in overlay when its page is evicted. Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies item at returned absolute position, and retries that rank/page check at most once if offsets shifted. Persisted Preview selection keeps listing identity, media kind included, from when item was selected (a stamp without a media kind lists both); changing active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations; its async navigation is fenced by captured account epoch so a late page cannot stamp selection into a newer account lifetime. ## Realtime refresh and external reveal @@ -10,7 +10,7 @@ Uploads, reconnects, and the signed-in user's completed queue items whose origin An external client asks for its results to be revealed by enqueueing with `POST /api/v1/queue/default/enqueue_batch` and `batch.destination` set to `gallery:reveal`. The request is honored only for the signed-in user's items (any user's on a single-user server) whose origin does not start with `webv2:`. Every other destination only refreshes, `gallery` included: legacy web sends it on every workflow and upscale run. -The reveal is best effort. Each completed item asks once; a reveal that stands down is dropped, not retried, except in a hidden tab. Every visible tab or device signed in as that user may reveal. The reveal takes the last non-intermediate image the item saved and lands on it as Find in Gallery does: board, page and selection, plus the starred-only filter and the images/assets view when the item needs them, after which Preview follows the selection and the grid scrolls to it. With the Gallery widget closed, the selection still changes, unseen. It raises no widget and leaves Canvas, Generate panels and the route untouched. Only while the Gallery widget is mounted is it announced, once, in the Gallery's polite live region; a Gallery mounted later does not repeat it. +The reveal is best effort. Each completed item asks once; a reveal that stands down is dropped, not retried, except in a hidden tab. Every visible tab or device signed in as that user may reveal. The reveal takes the last non-intermediate image the item saved and lands on it as Find in Gallery does: board, page and selection, plus the starred-only filter, the media filter and the images/assets view when the item needs them, after which Preview follows the selection and the grid scrolls to it. With the Gallery widget closed, the selection still changes, unseen. It raises no widget and leaves Canvas, Generate panels and the route untouched. Only while the Gallery widget is mounted is it announced, once, in the Gallery's polite live region; a Gallery mounted later does not repeat it. It never takes the Gallery from the user. These conditions are checked when the completion arrives, before and after its result is read, and immediately before the first Gallery change; the first failure drops the reveal: @@ -19,7 +19,7 @@ It never takes the Gallery from the user. These conditions are checked when the - no search text, semantic text or semantic query narrows the listing (an open but empty semantic field does not count); - at most one item is selected; - no other reveal, such as Find in Gallery or an image-map click, is locating its item; -- the user has not navigated the Gallery since arrival: board, selection, view, starred-only, search, page, or a claimed navigation; +- the user has not navigated the Gallery since arrival: board, selection, view, starred-only, media filter, search, page, or a claimed navigation; - no newer reveal request has arrived. Only the newest completion reveals, so a batch of several runs reveals at most its last completed item. A hidden tab defers instead: the newest request waits until the tab is shown, and the conditions are checked then. diff --git a/invokeai/frontend/webv2/src/features/gallery/contracts.ts b/invokeai/frontend/webv2/src/features/gallery/contracts.ts index c3ffd5d0e09..6be1ae2ac09 100644 --- a/invokeai/frontend/webv2/src/features/gallery/contracts.ts +++ b/invokeai/frontend/webv2/src/features/gallery/contracts.ts @@ -56,7 +56,10 @@ export { export { DEFAULT_GALLERY_SETTINGS, GALLERY_AUTO_ADD_FOLLOW, + getGalleryMediaFilter, getGallerySettings, + toGalleryMediaKind, + type GalleryMediaFilter, type GalleryPaginationMode, type GallerySettings, type GalleryThumbnailFit, @@ -64,6 +67,7 @@ export { export { getGalleryAutoAddBoardId, getGalleryCompareImage, + getGalleryMediaFilterMenuProps, getGalleryDestinationBoardId, getGalleryPage, getGallerySelectedBoardId, diff --git a/invokeai/frontend/webv2/src/features/gallery/core/selection.ts b/invokeai/frontend/webv2/src/features/gallery/core/selection.ts index 61ba294822c..f160aedc00b 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/selection.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/selection.ts @@ -238,6 +238,7 @@ export interface GalleryNavigationSnapshot { const NAVIGATION_VALUE_KEYS = [ 'galleryPage', 'galleryView', + 'mediaFilter', 'searchTerm', 'selectedBoardId', 'selectedImageName', diff --git a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts index 4e1bfd706bd..485a3fd5025 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts @@ -1,9 +1,21 @@ +import type { GalleryItemKind } from './items'; import type { GalleryBoardOrderBy, GalleryOrderDir } from './types'; export type GalleryThumbnailFit = 'square' | 'aspect'; export type GalleryPaginationMode = 'infinite' | 'paginated'; +/** Which media kinds the gallery lists: both, or only one. */ +export type GalleryMediaFilter = 'all' | 'image' | 'video'; + +/** Reads only the media filter, for subscribers that run on every Workbench change; unknown values read as both. */ +export const getGalleryMediaFilter = (values: Record): GalleryMediaFilter => + values.mediaFilter === 'image' || values.mediaFilter === 'video' ? values.mediaFilter : 'all'; + +/** The listing's media-kind restriction; undefined lists both kinds. */ +export const toGalleryMediaKind = (filter: GalleryMediaFilter): GalleryItemKind | undefined => + filter === 'all' ? undefined : filter; + /** The collapsible groups in the board panel, in render order. */ export const GALLERY_BOARD_SECTION_IDS = ['project', 'library', 'other-projects', 'dates', 'archived'] as const; @@ -35,6 +47,7 @@ export interface GallerySettings { collapsedBoardSections: GalleryBoardSectionId[]; imageDensityPercent: number; imageOrderDir: GalleryOrderDir; + mediaFilter: GalleryMediaFilter; /** Opt-in: a middle click on a tile opens the media in a new browser tab instead of the browser default. */ middleClickOpensInNewTab: boolean; paginationMode: GalleryPaginationMode; @@ -60,6 +73,7 @@ export const DEFAULT_GALLERY_SETTINGS: GallerySettings = { collapsedBoardSections: [], imageDensityPercent: 50, imageOrderDir: 'DESC', + mediaFilter: 'all', middleClickOpensInNewTab: false, paginationMode: 'infinite', showArchivedBoards: false, @@ -125,6 +139,7 @@ export const getGallerySettings = (values: Record): GallerySett ? Math.min(100, Math.max(0, values.imageDensityPercent)) : DEFAULT_GALLERY_SETTINGS.imageDensityPercent, imageOrderDir: isOrderDir(values.imageOrderDir) ? values.imageOrderDir : DEFAULT_GALLERY_SETTINGS.imageOrderDir, + mediaFilter: getGalleryMediaFilter(values), middleClickOpensInNewTab: values.middleClickOpensInNewTab === true, paginationMode, showArchivedBoards: diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts index 52de9f78659..81fd0d2185d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts @@ -27,6 +27,8 @@ import { getGalleryImagesByNames, getGalleryItemLocation, getGalleryVideoMetadata, + listGalleryDateBoardItemNames, + listGalleryItemNames, getGalleryImageWorkflow, getGalleryVideoWorkflow, imageMakeCanvasAssetChanges, @@ -195,6 +197,48 @@ describe('getGalleryItemLocation', () => { }); }); +describe('media kind restriction', () => { + beforeEach(() => { + accountLifecycle.activate('user-a'); + mocks.apiFetchJson.mockReset(); + mocks.apiFetchJson.mockResolvedValue({ + index: 0, + items: [], + kind: 'video', + name: 'clip.mp4', + total: 0, + total_count: 0, + }); + }); + + const requestedMediaKinds = () => + mocks.apiFetchJson.mock.calls.map(([url]) => new URLSearchParams(String(url).split('?')[1]).getAll('media_kind')); + const base = { boardId: 'board-1', galleryView: 'images', searchTerm: '' } as const; + + it('sends media_kind to every ordinary listing endpoint when one kind is chosen', async () => { + await listGalleryItems({ ...base, mediaKind: 'video' }); + await listGalleryItemNames({ ...base, mediaKind: 'video', orderDir: 'DESC' }); + await getGalleryItemLocation({ ...base, kind: 'video', mediaKind: 'video', name: 'clip.mp4', orderDir: 'DESC' }); + await listGalleryDateBoardItemNames({ + ...base, + boardId: 'by_date:2026-08-02', + mediaKind: 'image', + orderDir: 'DESC', + }); + + expect(requestedMediaKinds()).toEqual([['video'], ['video'], ['video'], ['image']]); + }); + + it('omits media_kind when both kinds are listed', async () => { + await listGalleryItems(base); + await listGalleryItemNames({ ...base, orderDir: 'DESC' }); + await getGalleryItemLocation({ ...base, kind: 'video', name: 'clip.mp4', orderDir: 'DESC' }); + await listGalleryDateBoardItemNames({ ...base, boardId: 'by_date:2026-08-02', orderDir: 'DESC' }); + + expect(requestedMediaKinds()).toEqual([[], [], [], []]); + }); +}); + describe('getGalleryImageByName', () => { beforeEach(() => { mocks.apiFetchJson.mockReset(); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts index 388168dba30..4e9877f92c4 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts @@ -3,6 +3,7 @@ import type { GalleryImageItem, GalleryItem, GalleryItemCategory, + GalleryItemKind, GalleryItemRef, GalleryItemsPage, GalleryVideoItem, @@ -537,6 +538,7 @@ const listPaletteDateBoardImageNames = async ({ createdFrom, createdTo, galleryView, + mediaKind: 'image', orderDir, searchTerm, signal, @@ -561,6 +563,8 @@ interface GalleryItemNamesRequest { createdFrom?: string; createdTo?: string; galleryView: GalleryView; + /** Only this media kind; absent lists both. */ + mediaKind?: GalleryItemKind; orderDir: GalleryOrderDir; searchTerm: string; signal?: AbortSignal; @@ -578,6 +582,7 @@ export const listGalleryItemNames = async ({ createdFrom, createdTo, galleryView, + mediaKind, orderDir, searchTerm, signal, @@ -589,6 +594,7 @@ export const listGalleryItemNames = async ({ created_from: createdFrom, created_to: createdTo, is_intermediate: false, + media_kind: mediaKind, order_dir: orderDir, search_term: searchTerm.trim() || undefined, starred, @@ -611,6 +617,7 @@ export const getGalleryItemLocation = ({ createdTo, galleryView, kind, + mediaKind, name, orderDir, searchTerm, @@ -624,6 +631,7 @@ export const getGalleryItemLocation = ({ created_to: createdTo, is_intermediate: false, kind, + media_kind: mediaKind, order_dir: orderDir, name, search_term: searchTerm.trim() || undefined, @@ -640,6 +648,7 @@ export const listGalleryDateBoardItemNames = async ({ createdFrom, createdTo, galleryView, + mediaKind, orderDir, searchTerm, signal, @@ -654,6 +663,7 @@ export const listGalleryDateBoardItemNames = async ({ const query = toSearchParams({ categories: galleryView === 'assets' ? assetCategories : imageCategories, + media_kind: mediaKind, order_dir: orderDir, search_term: searchTerm.trim() || undefined, starred, @@ -753,6 +763,8 @@ interface GalleryListRequest { createdTo?: string; galleryView: GalleryView; limit?: number; + /** Only this media kind; absent lists both. Ignored by the image-only palette listing. */ + mediaKind?: GalleryItemKind; offset?: number; orderDir?: GalleryOrderDir; searchTerm: string; @@ -772,6 +784,7 @@ export const listGalleryItems = async ({ galleryView, isIntermediate = false, limit = 100, + mediaKind, offset = 0, orderDir = 'DESC', searchTerm, @@ -785,6 +798,7 @@ export const listGalleryItems = async ({ created_to: createdTo, is_intermediate: isIntermediate, limit, + media_kind: mediaKind, offset, order_dir: orderDir, search_term: searchTerm.trim() || undefined, @@ -834,7 +848,8 @@ const toSemanticScopeParams = (boardId: string | undefined): { board_id?: string /** * Text and gallery-image searches use GET; URLs and registered files use POST. include_videos opts into kind-aware - * result hydration. The server ranks within `boardId` before applying the limit. + * result hydration; a set `mediaKind` overrides it server-side and ranks only that kind. The server ranks within + * `boardId` before applying the limit. */ export const searchGallerySemantic = async ( query: Exclude, @@ -842,10 +857,17 @@ export const searchGallerySemantic = async ( boardId, includeVideos = true, limit = SEMANTIC_SEARCH_MAX_RESULTS, + mediaKind, signal, - }: { boardId?: string; includeVideos?: boolean; limit?: number; signal?: AbortSignal } = {} + }: { + boardId?: string; + includeVideos?: boolean; + limit?: number; + mediaKind?: GalleryItemKind; + signal?: AbortSignal; + } = {} ): Promise => { - const scope = { ...toSemanticScopeParams(boardId), include_videos: includeVideos, limit }; + const scope = { ...toSemanticScopeParams(boardId), include_videos: includeVideos, limit, media_kind: mediaKind }; if (query.kind === 'url') { const params = toSearchParams({ image_url: query.url, ...scope }); @@ -918,25 +940,26 @@ export const fetchImageIndexAvailability = async (signal: AbortSignal): Promise< /** Kind-qualified refs retain relevance order for page hydration, range selection, and deletion neighbors. */ export const listSemanticGalleryItemNames = async ({ boardId, + mediaKind, query, signal, }: { boardId?: string; + mediaKind?: GalleryItemKind; query: GallerySemanticQuery; signal?: AbortSignal; }): Promise => { // A cluster query is an explicit member list held client-side (in proximity // order from the clicked map point); there is nothing to ask the server. if (query.kind === 'cluster') { - const itemKeys = getImageCluster(query.clusterId)?.itemKeys ?? []; + const items = (getImageCluster(query.clusterId)?.itemKeys ?? []) + .map(parseGalleryItemKey) + .filter((ref) => mediaKind === undefined || ref.kind === mediaKind); - return { - items: itemKeys.map(parseGalleryItemKey), - total: itemKeys.length, - }; + return { items, total: items.length }; } - const results = await searchGallerySemantic(query, { boardId, signal }); + const results = await searchGallerySemantic(query, { boardId, mediaKind, signal }); return { items: results.map((result) => result.ref), diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts index cc4cc14ee8f..cd368a78fa7 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts @@ -1,4 +1,4 @@ -import type { GalleryItem, GalleryItemRef, GalleryItemsPage } from '@features/gallery/core/items'; +import type { GalleryItem, GalleryItemKind, GalleryItemRef, GalleryItemsPage } from '@features/gallery/core/items'; import type { GallerySemanticQuery, GallerySemanticReference } from '@features/gallery/core/semanticImageQuery'; import type { GallerySettings } from '@features/gallery/core/settings'; import type { GalleryBoardOrderBy, GalleryOrderDir, GalleryView } from '@features/gallery/core/types'; @@ -65,6 +65,8 @@ export interface GalleryItemsFilter { /** Inclusive upper-bound calendar day (YYYY-MM-DD) on created_at. */ createdTo?: string; galleryView: GalleryView; + /** Only this media kind; absent lists both. Applies to ordinary and semantic listings alike. */ + mediaKind?: GalleryItemKind; orderDir?: GalleryOrderDir; searchTerm: string; /** @@ -82,6 +84,8 @@ export interface CanonicalGalleryItemsFilter { createdFrom?: string; createdTo?: string; galleryView: GalleryView; + /** Present only when restricted, so unrestricted keys match those from before the field existed. */ + mediaKind?: GalleryItemKind; orderDir: GalleryOrderDir; searchTerm: string; /** Label-free semantic reference: a file query is keyed by its registry id. */ @@ -137,6 +141,7 @@ export const canonicalizeGalleryItemsFilter = (filter: GalleryItemsFilter): Cano return { boardId: semantic.kind === 'cluster' ? '' : filter.boardId, galleryView: 'images', + ...(filter.mediaKind ? { mediaKind: filter.mediaKind } : {}), orderDir: 'DESC', searchTerm: '', semantic, @@ -148,6 +153,7 @@ export const canonicalizeGalleryItemsFilter = (filter: GalleryItemsFilter): Cano ...(filter.createdFrom ? { createdFrom: filter.createdFrom } : {}), ...(filter.createdTo ? { createdTo: filter.createdTo } : {}), galleryView: filter.galleryView, + ...(filter.mediaKind ? { mediaKind: filter.mediaKind } : {}), orderDir: filter.orderDir ?? 'DESC', searchTerm: filter.searchTerm.trim(), ...(filter.starred !== undefined ? { starred: filter.starred } : {}), @@ -211,7 +217,12 @@ const galleryItemNamesOptionsForOwner = (owner: AccountScope, filter: CanonicalG queryFn: async ({ signal }) => { const requestSignal = AbortSignal.any([signal, owner.signal]); const result = await (filter.semantic - ? listSemanticGalleryItemNames({ boardId: filter.boardId, query: filter.semantic, signal: requestSignal }) + ? listSemanticGalleryItemNames({ + boardId: filter.boardId, + mediaKind: filter.mediaKind, + query: filter.semantic, + signal: requestSignal, + }) : isDateBoardId(filter.boardId) ? listGalleryDateBoardItemNames({ ...filter, signal: requestSignal }) : listGalleryItemNames({ ...filter, signal: requestSignal })); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts index f2ddb9f8a71..bd0ffc5b77b 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts @@ -240,6 +240,31 @@ describe('listSemanticGalleryItemNames', () => { }); expect(mocks.apiFetchJson).not.toHaveBeenCalled(); }); + + it('ranks only the restricted media kind, server-side for searches and client-side for clusters', async () => { + mocks.apiFetchJson.mockReset(); + mocks.apiFetchJson.mockResolvedValue({ results: [] }); + const clusterId = registerImageCluster(['image:near.png', 'video:clip.mp4', 'image:far.png'], 'beaches'); + + await listSemanticGalleryItemNames({ + boardId: 'board-1', + mediaKind: 'video', + query: { kind: 'text', query: 'surf' }, + }); + await expect( + listSemanticGalleryItemNames({ mediaKind: 'image', query: { clusterId, kind: 'cluster' } }) + ).resolves.toEqual({ + items: [ + { kind: 'image', name: 'near.png' }, + { kind: 'image', name: 'far.png' }, + ], + total: 2, + }); + + expect(mocks.apiFetchJson.mock.calls.map(([path]) => path)).toEqual([ + '/api/v1/image_map/search?board_id=board-1&include_videos=true&limit=500&media_kind=video&q=surf', + ]); + }); }); describe('semantic filter canonicalization', () => { @@ -278,4 +303,28 @@ describe('semantic filter canonicalization', () => { expect(withLabelA.semantic).toEqual({ fileId: 'external-7', kind: 'file' }); expect(withLabelA).toEqual(withLabelB); }); + + it('keys a media-kind restriction on both listings and rankings, and leaves unrestricted keys unchanged', () => { + const semanticQuery = { kind: 'text', query: 'boats' } as const; + + // Unrestricted filters keep the exact shape they had before the field existed, so cached pages stay addressable. + expect(canonicalizeGalleryItemsFilter(baseFilter)).toEqual({ + boardId: 'none', + galleryView: 'images', + orderDir: 'DESC', + searchTerm: '', + }); + expect(canonicalizeGalleryItemsFilter({ ...baseFilter, semanticQuery })).toEqual({ + boardId: 'none', + galleryView: 'images', + orderDir: 'DESC', + searchTerm: '', + semantic: semanticQuery, + }); + expect(canonicalizeGalleryItemsFilter({ ...baseFilter, mediaKind: 'video' })).toMatchObject({ mediaKind: 'video' }); + expect(canonicalizeGalleryItemsFilter({ ...baseFilter, mediaKind: 'image', semanticQuery })).toMatchObject({ + mediaKind: 'image', + semantic: semanticQuery, + }); + }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx index 1257dd51fe0..3ee9c97abbb 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx @@ -213,6 +213,9 @@ void i18n.use(initReactI18next).init({ itemsAriaLabel: 'Gallery items', loadingBackendGallery: 'Loading gallery', noImagesMatch: 'No items', + noMatchingImages: 'No images match', + noMatchingVideos: 'No videos match', + showAllMedia: 'Show all media', noStarredItemsMatch: 'No starred items', showAllStarred: 'Show all', showAllStarredItems: 'Show all starred items', @@ -1756,6 +1759,26 @@ describe('GalleryImageGrid upload drop zone', () => { expect(host?.querySelector('[role="button"]')).toBeNull(); }); + it('names the media filter that emptied the listing and offers to show both kinds instead of the upload target', async () => { + const onlyVideos = { + ...getGallerySettings({ mediaFilter: 'video', paginationMode: 'paginated' }), + imageDensityPercent: 0, + }; + + await renderGallery(createGallery({ items: [], settings: onlyVideos })); + + expect(host?.textContent).toContain('No videos match'); + expect(host?.querySelector('input[type="file"]')).toBeNull(); + + const showAll = page.getByRole('button', { name: 'Show all media' }); + + await showAll.click(); + expect(actionMocks.updateSettings).toHaveBeenCalledExactlyOnceWith({ mediaFilter: 'all' }); + + await renderGallery(createGallery({ items: [], settings: { ...onlyVideos, mediaFilter: 'image' } })); + expect(host?.textContent).toContain('No images match'); + }); + it('keeps the no-match message for an empty virtual (date) board instead of the upload target', async () => { await renderGallery( createGallery({ diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx index ceb8fe8f842..f9314baddfc 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx @@ -32,6 +32,7 @@ import { type FocusEvent, type KeyboardEvent, type ReactNode, + type RefObject, } from 'react'; import { flushSync } from 'react-dom'; import { defaultRangeExtractor, useVirtualizer } from 'react-hook-tanstack-virtual'; @@ -400,7 +401,12 @@ const focusVisibleGridContent = (viewport: HTMLElement | null, edge: 'first' | ' }; /** Measure viewport width for columns so both layouts share the same grid. */ -export const GalleryImageGrid = () => { +export const GalleryImageGrid = ({ + mediaFilterTriggerRef, +}: { + /** The layout's media filter trigger, which takes focus when "Show all media" removes itself. */ + mediaFilterTriggerRef?: RefObject; +} = {}) => { const { t } = useTranslation(); const { actions, @@ -431,6 +437,7 @@ export const GalleryImageGrid = () => { const viewportRef = useRef(null); const { imageDensityPercent, + mediaFilter, middleClickOpensInNewTab, paginationMode, progressSectionCollapsed, @@ -1650,6 +1657,12 @@ export const GalleryImageGrid = () => { actions.setStarredOnly(true); }, [actions]); + // The button leaves with the empty state; focus lands on the control that owns the filter. + const handleShowAllMedia = useCallback(() => { + mediaFilterTriggerRef?.current?.focus(); + actions.updateSettings({ mediaFilter: 'all' }); + }, [actions, mediaFilterTriggerRef]); + const handleToggleStarred = useCallback( (item: GalleryItem) => void itemActions.setItemsStarred([{ kind: item.kind, name: item.name }], !item.starred), [itemActions] @@ -1857,6 +1870,26 @@ export const GalleryImageGrid = () => { ) : isEmpty && isStarredFailedEmpty && listing.status !== 'loading' ? ( + ) : isEmpty && mediaFilter !== 'all' && listing.status !== 'loading' ? ( + // The media filter can hide everything on a board that has items; say so instead of inviting an upload. + + + {t( + mediaFilter === 'video' ? 'widgets.gallery.noMatchingVideos' : 'widgets.gallery.noMatchingImages' + )} + + + ) : isEmpty ? ( listing.status === 'loading' || hasActiveSearch || isVirtualBoard || gallery.starredOnly ? ( diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLayout.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLayout.browser.test.tsx index 4900486561d..0304116b367 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLayout.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLayout.browser.test.tsx @@ -11,7 +11,7 @@ import { system } from '@theme/system'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import type { GalleryStateView } from './galleryStateView'; import type { GalleryWidgetContextValue } from './GalleryWidgetContext'; @@ -421,6 +421,80 @@ describe('gallery layout shells', () => { } }); + it('places the media filter between the starred filter and the sort menu in both shells, operable by keyboard', async () => { + for (const Layout of [GalleryStackedLayout, GalleryWideLayout]) { + (contextBase.actions.updateSettings as ReturnType).mockClear(); + setGallery(createGallery()); + await renderLayout(Layout); + + const toolbarButtons = [...(host?.querySelectorAll('button') ?? [])]; + const starIndex = toolbarButtons.findIndex( + (button) => button.getAttribute('aria-label') === 'widgets.gallery.starredOnly' + ); + const filter = host?.querySelector('[data-gallery-media-filter-menu]'); + const sortIndex = toolbarButtons.findIndex( + (button) => button.getAttribute('aria-label') === 'widgets.gallery.imageSort' + ); + + expect(filter, 'media filter did not render in the toolbar').not.toBeNull(); + expect(toolbarButtons.indexOf(filter!)).toBe(starIndex + 1); + expect(sortIndex).toBe(starIndex + 2); + expect(filter?.getAttribute('aria-label')).toBe('widgets.gallery.mediaFilterTrigger'); + + filter?.focus(); + await userEvent.keyboard('{Enter}'); + const videosOnly = page.getByRole('menuitemradio', { name: 'widgets.gallery.mediaFilterVideos' }); + + await expect.element(videosOnly).toBeVisible(); + await expect + .element(page.getByRole('menuitemradio', { name: 'widgets.gallery.mediaFilterAll' })) + .toHaveAttribute('aria-checked', 'true'); + await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + + expect(contextBase.actions.updateSettings).toHaveBeenCalledExactlyOnceWith({ mediaFilter: 'video' }); + await expect.element(videosOnly).not.toBeInTheDocument(); + } + }); + + it('shows a restricted media filter as an active control', async () => { + const triggerBackground = () => + getComputedStyle(host!.querySelector('[data-gallery-media-filter-menu]')!).backgroundColor; + + await renderLayout(GalleryWideLayout); + const inactiveBackground = triggerBackground(); + + setGallery(createGallery({ settings: { ...DEFAULT_GALLERY_SETTINGS, mediaFilter: 'image' } })); + await renderLayout(GalleryWideLayout); + + const trigger = host?.querySelector('[data-gallery-media-filter-menu]'); + + expect(trigger?.getAttribute('data-media-filter')).toBe('image'); + expect(trigger?.hasAttribute('data-active')).toBe(true); + // The cue is visible, not just an attribute: a ghost trigger has no fill. + expect(triggerBackground()).not.toBe(inactiveBackground); + }); + + it('moves focus to the media filter when Show all media removes the empty state, in both shells', async () => { + for (const Layout of [GalleryStackedLayout, GalleryWideLayout]) { + (contextBase.actions.updateSettings as ReturnType).mockClear(); + setGallery(createGallery({ items: [], settings: { ...DEFAULT_GALLERY_SETTINGS, mediaFilter: 'video' } })); + await renderLayout(Layout); + + const showAll = [...(host?.querySelectorAll('button') ?? [])].find( + (button) => button.textContent === 'widgets.gallery.showAllMedia' + ); + + expect(showAll, 'Show all media did not render').toBeDefined(); + showAll?.focus(); + await act(() => { + showAll?.click(); + }); + + expect(contextBase.actions.updateSettings).toHaveBeenCalledExactlyOnceWith({ mediaFilter: 'all' }); + expect(document.activeElement?.hasAttribute('data-gallery-media-filter-menu')).toBe(true); + } + }); + it('moves focus to the toolbar toggle when Show all removes the strip header', async () => { const starredItem = { ...createItem('starred.png'), starred: true }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx index 02e1a387171..9a956cd333e 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx @@ -1,9 +1,12 @@ -import { Flex, HStack, Spacer, Stack } from '@chakra-ui/react'; +import type { GalleryMediaFilter } from '@features/gallery/core/settings'; + +import { Flex, HStack, Stack } from '@chakra-ui/react'; import { GALLERY_BOARD_PANEL_MAX_HEIGHT_PX, GALLERY_BOARD_PANEL_MIN_HEIGHT_PX, GALLERY_MIN_GRID_HEIGHT_PX, } from '@features/gallery/core/settings'; +import { IconRadioMenu } from '@platform/ui/Menu'; import { ResizeHandle } from '@platform/ui/ResizeHandle'; import { segmentTabsPanelId, segmentTabsTabId } from '@platform/ui/SegmentTabs'; import { useCallback, useId, useMemo, useRef, useState } from 'react'; @@ -15,6 +18,7 @@ import { GalleryItemSearch } from './GalleryItemSearch'; import { GalleryItemSortMenu } from './GalleryItemSortMenu'; import { GallerySelectionBar } from './GallerySelectionBar'; import { GalleryStarredFilterToggle } from './GalleryStarredFilterToggle'; +import { getGalleryMediaFilterMenuProps } from './galleryStateView'; import { GalleryUploadButton } from './GalleryUploadButton'; import { GalleryViewTabs } from './GalleryViewTabs'; import { useGalleryWidget } from './GalleryWidgetContext'; @@ -32,6 +36,13 @@ export const GalleryStackedLayout = () => { const containerObserverRef = useRef(null); const controlsObserverRef = useRef(null); + // The grid's "Show all media" hands focus here when it removes itself. + const mediaFilterTriggerRef = useRef(null); + const handleMediaFilterChange = useCallback( + (mediaFilter: GalleryMediaFilter) => actions.updateSettings({ mediaFilter }), + [actions] + ); + const containerRef = useCallback((element: HTMLDivElement | null) => { containerObserverRef.current?.disconnect(); containerObserverRef.current = null; @@ -121,16 +132,25 @@ export const GalleryStackedLayout = () => { )} - + {/* The controls wrap below the tabs as a group when the panel is too narrow for one row. */} + - - - - + + + + + + @@ -143,7 +163,7 @@ export const GalleryStackedLayout = () => { minW="0" role="tabpanel" > - + diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx index a988e57662f..0d0a4964710 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx @@ -1,5 +1,8 @@ +import type { GalleryMediaFilter } from '@features/gallery/core/settings'; + import { Box, Flex, HStack, Spacer, Stack } from '@chakra-ui/react'; import { GALLERY_BOARD_PANEL_MAX_WIDTH_PX, GALLERY_BOARD_PANEL_MIN_WIDTH_PX } from '@features/gallery/core/settings'; +import { IconRadioMenu } from '@platform/ui/Menu'; import { ResizeHandle } from '@platform/ui/ResizeHandle'; import { segmentTabsPanelId, segmentTabsTabId } from '@platform/ui/SegmentTabs'; import { useCallback, useId, useRef } from 'react'; @@ -11,6 +14,7 @@ import { GalleryItemSearch } from './GalleryItemSearch'; import { GalleryItemSortMenu } from './GalleryItemSortMenu'; import { GallerySelectionBar } from './GallerySelectionBar'; import { GalleryStarredFilterToggle } from './GalleryStarredFilterToggle'; +import { getGalleryMediaFilterMenuProps } from './galleryStateView'; import { GalleryUploadButton } from './GalleryUploadButton'; import { GalleryViewTabs } from './GalleryViewTabs'; import { useGalleryWidget } from './GalleryWidgetContext'; @@ -24,6 +28,13 @@ export const GalleryWideLayout = () => { const viewTabsIdBase = useId(); const boardPanelRef = useRef(null); + // The grid's "Show all media" hands focus here when it removes itself. + const mediaFilterTriggerRef = useRef(null); + const handleMediaFilterChange = useCallback( + (mediaFilter: GalleryMediaFilter) => actions.updateSettings({ mediaFilter }), + [actions] + ); + const handleCommitWidth = useCallback( (boardPanelWidthPx: number) => actions.updateSettings({ boardPanelWidthPx }), [actions] @@ -66,6 +77,13 @@ export const GalleryWideLayout = () => { + { ps="3" role="tabpanel" > - + diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx index f9c7ed8fabc..d320cc0810d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx @@ -6,7 +6,7 @@ import type { TFunction } from 'i18next'; import { toGalleryItemKey, toGalleryItemRef } from '@features/gallery/core/items'; import { getBoundedRecentImages } from '@features/gallery/core/recentImages'; import { getGalleryRevealRequest, subscribeGalleryRevealRequests } from '@features/gallery/core/selection'; -import { getGallerySettings } from '@features/gallery/core/settings'; +import { getGallerySettings, toGalleryMediaKind } from '@features/gallery/core/settings'; import { GALLERY_PAGE_SIZE, galleryItemNamesOptions } from '@features/gallery/data/queries'; import { StatusWidgetChip } from '@platform/ui'; import { useQueryClient } from '@tanstack/react-query'; @@ -111,6 +111,7 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: GalleryWidg const semanticQuery = useGallerySemanticImageQuery(galleryValues.semanticImageQuery); const data = useGalleryData({ galleryView, + mediaKind: toGalleryMediaKind(settings.mediaFilter), page, projectBoardId: getGalleryProjectBoardId(galleryValues), recentImages, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts index 6754f3a6225..6a836e327ec 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts @@ -157,7 +157,11 @@ describe('gallery state view', () => { }); it('parses persisted gallery settings with safe defaults', () => { - const gallery = getGalleryStateView({ boardOrderBy: 'board_name', starredSectionCollapsed: 'yes' }, boards, []); + const gallery = getGalleryStateView( + { boardOrderBy: 'board_name', mediaFilter: 'audio', starredSectionCollapsed: 'yes' }, + boards, + [] + ); expect(gallery.settings).toEqual({ autoAddBoardId: 'follow', @@ -169,6 +173,7 @@ describe('gallery state view', () => { collapsedBoardSections: [], imageDensityPercent: 50, imageOrderDir: 'DESC', + mediaFilter: 'all', middleClickOpensInNewTab: false, paginationMode: 'infinite', showArchivedBoards: false, @@ -183,6 +188,7 @@ describe('gallery state view', () => { expect(getGalleryStateView({ starredSectionCollapsed: true }, boards, []).settings.starredSectionCollapsed).toBe( true ); + expect(getGalleryStateView({ mediaFilter: 'video' }, boards, []).settings.mediaFilter).toBe('video'); }); it('exposes comparison state only while an image selection differs from the compare image', () => { @@ -238,6 +244,10 @@ describe('gallery state view', () => { expect(pageOf({ searchTerm: 'cats' })).toBeNull(); expect(pageOf({ starredOnly: true })).toBeNull(); expect(pageOf({ starredOnly: true }, { starredOnly: true })).toBe(2); + // A stamp without a media kind was made in a listing of both kinds, whatever the filter is now. + expect(pageOf({ mediaFilter: 'video' })).toBeNull(); + expect(pageOf({ mediaFilter: 'video' }, { mediaKind: 'video' })).toBe(2); + expect(pageOf({}, { mediaKind: 'image' })).toBeNull(); // A starred selection sits in the strip, so no page of the unstarred grid holds it. expect(pageOf({ selectedImage: { ...createImageItem('starred.png'), starred: true } })).toBeNull(); expect( @@ -259,6 +269,12 @@ describe('gallery state view', () => { expect( getGallerySelectedImageQuery({ selectedImageQuery: { starredOnly: true }, starredOnly: false }) ).toMatchObject({ starredOnly: true }); + // Unlike the other fields, a legacy stamp's missing media kind never falls back to the live filter. + expect(getGallerySelectedImageQuery({ mediaFilter: 'video', selectedImageQuery: {} }).mediaKind).toBeNull(); + expect(getGallerySelectedImageQuery({ mediaFilter: 'video' }).mediaKind).toBeNull(); + expect( + getGallerySelectedImageQuery({ mediaFilter: 'all', selectedImageQuery: { mediaKind: 'video' } }).mediaKind + ).toBe('video'); expect(getGallerySelectedImageQuery({ starredOnly: true })).toMatchObject({ starredOnly: true }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts index 8dcd4bfe110..acf87d38ab8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts @@ -1,6 +1,14 @@ import type { GalleryBoard, GalleryImage, GalleryOrderDir, GalleryView } from '@features/gallery/core/types'; +import type { IconRadioMenuProps } from '@platform/ui/Menu'; +import type { TFunction } from 'i18next'; -import { isDateBoardId, toGalleryItemKey, type GalleryItem, type GalleryItemKey } from '@features/gallery/core/items'; +import { + isDateBoardId, + toGalleryItemKey, + type GalleryItem, + type GalleryItemKey, + type GalleryItemKind, +} from '@features/gallery/core/items'; import { getPersistedSelectedGalleryItemKeys, getSelectedGalleryImageFromValues, @@ -10,7 +18,49 @@ import { parseGallerySemanticReference, type GallerySemanticReference, } from '@features/gallery/core/semanticImageQuery'; -import { GALLERY_AUTO_ADD_FOLLOW, getGallerySettings, type GallerySettings } from '@features/gallery/core/settings'; +import { + GALLERY_AUTO_ADD_FOLLOW, + getGallerySettings, + toGalleryMediaKind, + type GalleryMediaFilter, + type GallerySettings, +} from '@features/gallery/core/settings'; +import { FilmIcon, ImageIcon, LayersIcon } from 'lucide-react'; + +const GALLERY_MEDIA_FILTER_OPTIONS = [ + { icon: LayersIcon, labelKey: 'widgets.gallery.mediaFilterAll', value: 'all' }, + { icon: ImageIcon, labelKey: 'widgets.gallery.mediaFilterImages', value: 'image' }, + { icon: FilmIcon, labelKey: 'widgets.gallery.mediaFilterVideos', value: 'video' }, +] as const; + +/** + * The media-type menu's content for `IconRadioMenu`, shared by every surface that edits the Gallery's per-project + * media filter (its titlebar and the Image Map header). It lives in this boot-loaded view module, exported through + * `contracts`, so those lazily loaded widgets add no module of their own. + */ +export const getGalleryMediaFilterMenuProps = ( + t: TFunction, + value: GalleryMediaFilter +): Pick< + IconRadioMenuProps, + 'isActive' | 'label' | 'options' | 'tooltip' | 'triggerAttributes' | 'triggerLabel' +> => { + const options = GALLERY_MEDIA_FILTER_OPTIONS.map((option) => ({ + icon: option.icon, + label: t(option.labelKey), + value: option.value, + })); + const currentLabel = options.find((option) => option.value === value)?.label ?? options[0].label; + + return { + isActive: value !== 'all', + label: t('widgets.gallery.mediaFilter'), + options, + tooltip: t('widgets.gallery.mediaFilterTooltip', { option: currentLabel }), + triggerAttributes: { 'data-gallery-media-filter-menu': '', 'data-media-filter': value }, + triggerLabel: t('widgets.gallery.mediaFilterTrigger', { option: currentLabel }), + }; +}; /** Leave the placeholder name empty; getGalleryBoardLabel localizes it from kind. */ const UNCATEGORIZED_BOARD: GalleryBoard = { @@ -156,6 +206,11 @@ export interface GallerySelectedImageQuery { boardId: string; galleryView: GalleryView; imageOrderDir: GalleryOrderDir; + /** + * The listing's media-kind restriction when the item was selected; null lists both. Stamps written before the field + * existed read as null, never as the current filter: they were made in a listing of both kinds. + */ + mediaKind: GalleryItemKind | null; page: number; paginationMode: 'infinite' | 'paginated'; searchTerm: string; @@ -183,6 +238,7 @@ export const getGallerySelectedImageQuery = (values: Record): G : 'images', imageOrderDir: query?.imageOrderDir === 'ASC' || query?.imageOrderDir === 'DESC' ? query.imageOrderDir : settings.imageOrderDir, + mediaKind: query?.mediaKind === 'image' || query?.mediaKind === 'video' ? query.mediaKind : null, page: query && typeof query.page === 'number' && Number.isFinite(query.page) ? Math.max(0, Math.floor(query.page)) @@ -199,8 +255,8 @@ export const getGallerySelectedImageQuery = (values: Record): G }; /** - * The selection was made in the listing the view shows: its stamp names the same board, view, order, search and - * starred filter. A selection stamped elsewhere (another board, or before a search or filter changed) is not this + * The selection was made in the listing the view shows: its stamp names the same board, view, order, search, + * starred filter and media kind. A selection stamped elsewhere (another board, or before a search or filter changed) is not this * listing's, though it persists across those switches. */ export const isGallerySelectionInScope = ( @@ -210,6 +266,7 @@ export const isGallerySelectionInScope = ( selectedImageQuery.boardId === scope.selectedBoardId && selectedImageQuery.galleryView === scope.galleryView && selectedImageQuery.imageOrderDir === scope.settings.imageOrderDir && + selectedImageQuery.mediaKind === (toGalleryMediaKind(scope.settings.mediaFilter) ?? null) && selectedImageQuery.searchTerm === scope.searchTerm && selectedImageQuery.starredOnly === scope.starredOnly; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts index 059cf7020b7..248fb9d062b 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts @@ -103,6 +103,17 @@ describe('getGalleryRecentItems', () => { }) ).toEqual([]); }); + + it('hides local outputs whose media kind the listing excludes', () => { + const recent = asGenerated(createImage(1, { imageName: 'recent-1.png' })); + const namesFor = (mediaKind: 'image' | 'video') => + getGalleryRecentItems({ backendItems: [], filter: { ...filter, mediaKind }, recentImages: [recent] }).map( + (item) => item.name + ); + + expect(namesFor('video')).toEqual([]); + expect(namesFor('image')).toEqual(['recent-1.png']); + }); }); describe('mergeGalleryItemWindow', () => { diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts index a033e5678b2..5608e3d1c60 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts @@ -1,4 +1,4 @@ -import type { GalleryItem } from '@features/gallery/core/items'; +import type { GalleryItem, GalleryItemKind } from '@features/gallery/core/items'; import type { GallerySemanticReference } from '@features/gallery/core/semanticImageQuery'; import type { GallerySettings } from '@features/gallery/core/settings'; import type { GalleryBoard, GalleryView, GeneratedImageContract } from '@features/gallery/core/types'; @@ -160,6 +160,10 @@ const isRecentItemVisible = (item: GalleryItem, filter: GalleryItemsFilter): boo return false; } + if (filter.mediaKind !== undefined && item.kind !== filter.mediaKind) { + return false; + } + const hasMatchingBoard = filter.boardId === ALL_READABLE_BOARDS_ID || filter.boardId === item.boardId; const hasMatchingCategory = filter.galleryView === 'images' @@ -254,6 +258,7 @@ export const isGalleryWindowTruncated = ({ export const useGalleryData = ({ galleryView, keepPreviousScope = false, + mediaKind, page, projectBoardId, recentImages, @@ -267,6 +272,11 @@ export const useGalleryData = ({ galleryView: GalleryView; /** Hold the previous scope's items as `previousScopeItems` while a new scope loads. */ keepPreviousScope?: boolean; + /** + * Only this media kind; absent lists both. Passed explicitly rather than read from `settings` because consumers + * like the picker list both kinds whatever the Gallery widget shows. + */ + mediaKind?: GalleryItemKind; page: number; projectBoardId: string | null; recentImages: readonly GeneratedImageContract[]; @@ -295,6 +305,7 @@ export const useGalleryData = ({ createdFrom: dateParse.range?.from, createdTo: dateParse.range?.to, galleryView, + ...(mediaKind ? { mediaKind } : {}), orderDir: settings.imageOrderDir, searchTerm: dateParse.text, ...(semanticQuery ? { semanticQuery } : {}), @@ -306,6 +317,7 @@ export const useGalleryData = ({ dateParse.range?.to, dateParse.text, galleryView, + mediaKind, semanticQuery, settings.imageOrderDir, starred, diff --git a/invokeai/frontend/webv2/src/platform/ui/Menu.tsx b/invokeai/frontend/webv2/src/platform/ui/Menu.tsx index 5491094a076..840143b8a5f 100644 --- a/invokeai/frontend/webv2/src/platform/ui/Menu.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/Menu.tsx @@ -1,11 +1,12 @@ import type { LucideIcon } from 'lucide-react'; -import type { ComponentProps, ReactNode } from 'react'; +import type { ComponentProps, ReactNode, Ref } from 'react'; -import { Box, HStack, Icon, Menu, Stack, Text, useMenuContext } from '@chakra-ui/react'; +import { Box, HStack, Icon, Menu, Portal, Stack, Text, useMenuContext } from '@chakra-ui/react'; import { useMountEffect } from '@platform/react/useMountEffect'; import { createExternalStore } from '@platform/state/externalStore'; -import { useCallback, useId } from 'react'; +import { useCallback, useId, useMemo } from 'react'; +import { IconButton } from './Button'; import { Tooltip } from './Tooltip'; import { useRegisterWidgetOverlay } from './widgetOverlays'; @@ -122,6 +123,101 @@ export const MenuIconItem = ({ disabled, icon, iconFill, label, onSelect, tone, ); +export interface IconRadioMenuOption { + value: Value; + label: string; + /** Also the trigger's icon while this option is chosen. */ + icon: LucideIcon; +} + +export interface IconRadioMenuProps { + /** What is being chosen; heads the menu's options. */ + label: string; + /** The trigger's accessible name; name the current choice, since the trigger shows only its icon. */ + triggerLabel: string; + tooltip: ReactNode; + options: readonly IconRadioMenuOption[]; + value: Value; + /** A choice that narrows what the surface shows reads as an active control. */ + isActive?: boolean; + size?: 'sm' | 'md'; + /** A stable id lets another surface hand focus to the trigger; a generated one otherwise. */ + triggerId?: string; + triggerRef?: Ref; + triggerAttributes?: Readonly>; + onValueChange: (value: Value) => void; +} + +const ICON_RADIO_MENU_POSITIONING = { placement: 'bottom-end' } as const; + +/** A compact single-choice menu behind an icon trigger that shows the current choice. */ +export const IconRadioMenu = ({ + isActive = false, + label, + onValueChange, + options, + size = 'sm', + tooltip, + triggerAttributes, + triggerId, + triggerLabel, + triggerRef, + value, +}: IconRadioMenuProps) => { + const generatedId = useId(); + // Shared ids let the tooltip ride the trigger without wrapping Menu.Trigger, which would swallow its anchor ref. + const ids = useMemo(() => ({ trigger: triggerId ?? generatedId }), [generatedId, triggerId]); + const current = options.find((option) => option.value === value) ?? options[0]; + const handleValueChange = useCallback( + (event: { value: string }) => { + const next = options.find((option) => option.value === event.value); + + if (next && next.value !== value) { + onValueChange(next.value); + } + }, + [onValueChange, options, value] + ); + + return ( + + + + + {current ? : null} + + + + + + + + {label} + + {options.map((option) => ( + + + {option.label} + + ))} + + + + + + + ); +}; + interface ContextMenuAnchor { x: number; y: number; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts index 6ab50d4ac37..2f516fa6dbf 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealExternalGeneration.test.ts @@ -331,6 +331,7 @@ describe('external generation reveal', () => { it.each([ ['galleryPage', 3], ['galleryView', 'assets'], + ['mediaFilter', 'video'], ['searchTerm', ''], ['selectedBoardId', 'board-2'], ['selectedImageName', 'image:other.png'], diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts index d0bbd855803..9a3fb031174 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts @@ -183,6 +183,49 @@ describe('revealGalleryItem', () => { expect(commands.gallery.selectItem).toHaveBeenCalledOnce(); }); + it('resets a media filter that hides the item and locates it in the listing of both kinds', async () => { + mocks.settings = { ...mocks.settings, mediaFilter: 'video' }; + const { commands, context } = createContext(); + + await revealGalleryItem(context, { kind: 'image', name: item.name }, ticket()); + + expect(mocks.fetchVerifiedPage.mock.calls[0]?.[1]).not.toHaveProperty('mediaKind'); + expect(commands.widgets.patchValues).toHaveBeenCalledWith('gallery', { + mediaFilter: 'all', + searchTerm: '', + semanticImageQuery: null, + semanticSearchText: null, + starredOnly: false, + }); + expect(commands.gallery.selectItem).toHaveBeenCalledWith(item, 'project-1', 2); + }); + + it('keeps a media filter that shows the item and locates it within that kind', async () => { + mocks.settings = { ...mocks.settings, mediaFilter: 'image' }; + const { commands, context } = createContext(); + + await revealGalleryItem(context, { kind: 'image', name: item.name }, ticket()); + + expect(mocks.fetchVerifiedPage.mock.calls[0]?.[1]).toMatchObject({ mediaKind: 'image' }); + expect(commands.widgets.patchValues).not.toHaveBeenCalled(); + expect(commands.gallery.selectItem).toHaveBeenCalledOnce(); + }); + + it('drops the landing when the media filter changes while the locator is in flight', async () => { + mocks.fetchVerifiedPage.mockImplementation(() => { + mocks.settings = { ...mocks.settings, mediaFilter: 'video' }; + + return Promise.resolve({ index: 127, offset: 120, page: { items: [item], total: 200 }, total: 200 }); + }); + const { commands, context } = createContext(); + + await revealGalleryItem(context, { kind: 'image', name: item.name }, ticket()); + + expect(commands.widgets.patchValues).not.toHaveBeenCalled(); + expect(commands.gallery.selectBoard).not.toHaveBeenCalled(); + expect(commands.gallery.selectItem).not.toHaveBeenCalled(); + }); + it('discards a gesture when its captured account epoch changes during media resolution', async () => { let resolveItem!: (resolved: GalleryItem) => void; mocks.resolve.mockReturnValue( diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts index b7af3c7d94a..183b52ff657 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts @@ -5,7 +5,12 @@ import type { QueryClient } from '@tanstack/react-query'; import type { WorkbenchCommands, WorkbenchQueries } from '@workbench/workbenchStore'; import { galleryItems, toGalleryItemKey } from '@features/gallery'; -import { getGallerySettings, isGalleryNavigationCurrent, requestGalleryItemReveal } from '@features/gallery/contracts'; +import { + getGallerySettings, + isGalleryNavigationCurrent, + requestGalleryItemReveal, + toGalleryMediaKind, +} from '@features/gallery/contracts'; import { fetchVerifiedGalleryItemPage, GALLERY_PAGE_SIZE, galleryBoardsOptions } from '@features/gallery/queries'; import { assertAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { getProjectWidgetValues } from '@workbench/widgetState'; @@ -73,9 +78,13 @@ export const revealGalleryItem = ( const targetView: GalleryView = image.category === 'general' ? 'images' : 'assets'; // Match the post-reveal listing filters and starred partition so prefetched pages enter the gallery's cache. const wantsStarredOnly = image.starred === true; + // A media filter that hides the item's kind is reset to both, like the other filters; one that shows it stays. + const currentMediaKind = toGalleryMediaKind(settings.mediaFilter); + const resetsMediaFilter = currentMediaKind !== undefined && currentMediaKind !== image.kind; const listingFilter = { boardId: image.boardId, galleryView: targetView, + ...(currentMediaKind && !resetsMediaFilter ? { mediaKind: currentMediaKind } : {}), orderDir: settings.imageOrderDir, searchTerm: '', starred: wantsStarredOnly, @@ -107,10 +116,9 @@ export const revealGalleryItem = ( const values = getGalleryValues(); const settingsNow = getGallerySettings(values); - // The listing's ordering may have changed while the name list was - // in flight (sort direction); the computed index describes the old - // ordering, so the page landing is dropped. - if (settingsNow.imageOrderDir !== settings.imageOrderDir) { + // The listing's ordering or media filter may have changed while the locator was in flight; the computed index + // describes the old listing, so the page landing is dropped. + if (settingsNow.imageOrderDir !== settings.imageOrderDir || settingsNow.mediaFilter !== settings.mediaFilter) { return; } @@ -130,10 +138,12 @@ export const revealGalleryItem = ( // Clear filters to match the indexed listing and select the item's starred partition. if ( hasSearch || + resetsMediaFilter || (values.starredOnly === true) !== wantsStarredOnly || (values.semanticImageQuery !== null && values.semanticImageQuery !== undefined) ) { commands.widgets.patchValues('gallery', { + ...(resetsMediaFilter ? { mediaFilter: 'all' } : {}), searchTerm: '', semanticImageQuery: null, semanticSearchText: null, diff --git a/invokeai/frontend/webv2/src/workbench/image-map/api.ts b/invokeai/frontend/webv2/src/workbench/image-map/api.ts index 8320cd88d96..04a6c17ef9e 100644 --- a/invokeai/frontend/webv2/src/workbench/image-map/api.ts +++ b/invokeai/frontend/webv2/src/workbench/image-map/api.ts @@ -1,4 +1,4 @@ -import type { GalleryItemKey, GalleryItemRef } from '@features/gallery/contracts'; +import type { GalleryItemKey, GalleryItemKind, GalleryItemRef } from '@features/gallery/contracts'; import { toGalleryItemKey } from '@features/gallery/contracts'; import { apiFetchJson } from '@platform/transport/http'; @@ -58,8 +58,26 @@ interface BackendImageMapPointsResponse { updated_at: string | null; } -/** Opt into videos because item-kind routing resolves both backend namespaces. */ -const INCLUDE_VIDEOS_PARAM = { include_videos: 'true' } as const; +/** + * Opt into videos because item-kind routing resolves both backend namespaces. A media kind, when set, overrides that + * server-side and serves only its own kind. + */ +const toServedKindsParams = (mediaKind: GalleryItemKind | undefined): URLSearchParams => { + const query = new URLSearchParams({ include_videos: 'true' }); + + if (mediaKind !== undefined) { + query.set('media_kind', mediaKind); + } + + return query; +}; + +/** What a points or labels request clusters: both must send the same values, or their visible hashes differ. */ +export interface ImageMapClusteringOptions { + eps?: number; + mediaKind?: GalleryItemKind; + minSamples?: number; +} const mapPoints = (body: BackendImageMapPointsResponse): ImageMapPoints => ({ clusterEps: body.cluster_eps ?? null, @@ -78,8 +96,8 @@ const mapPoints = (body: BackendImageMapPointsResponse): ImageMapPoints => ({ visibleHash: body.visible_hash ?? null, }); -export const fetchImageMapPoints = async (options?: { eps?: number; minSamples?: number }): Promise => { - const query = new URLSearchParams(INCLUDE_VIDEOS_PARAM); +export const fetchImageMapPoints = async (options?: ImageMapClusteringOptions): Promise => { + const query = toServedKindsParams(options?.mediaKind); if (options?.eps !== undefined) { query.set('eps', String(options.eps)); @@ -116,9 +134,9 @@ interface BackendImageMapStatusResponse { } /** Read index progress and encoder availability without fetching/clustering the points. */ -export const fetchImageMapStatus = async (): Promise => { +export const fetchImageMapStatus = async (mediaKind?: GalleryItemKind): Promise => { const body = await apiFetchJson( - `/api/v1/image_map/status?${new URLSearchParams(INCLUDE_VIDEOS_PARAM).toString()}` + `/api/v1/image_map/status?${toServedKindsParams(mediaKind).toString()}` ); const { embedded = 0, failed = 0, total = 0 } = body.index ?? {}; @@ -155,13 +173,12 @@ export interface ImageMapClusterLabels { updatedAt: string | null; } -export const fetchImageMapClusterLabels = async (options?: { - eps?: number; - minSamples?: number; -}): Promise => { +export const fetchImageMapClusterLabels = async ( + options?: ImageMapClusteringOptions +): Promise => { // Must match /points exactly: the two responses' visible hashes are compared // before the labels are used, and a set difference discards every label. - const query = new URLSearchParams(INCLUDE_VIDEOS_PARAM); + const query = toServedKindsParams(options?.mediaKind); if (options?.eps !== undefined) { query.set('eps', String(options.eps)); diff --git a/invokeai/frontend/webv2/src/workbench/image-map/imageMap.test.ts b/invokeai/frontend/webv2/src/workbench/image-map/imageMap.test.ts index 5f2b0ac657e..adf13274ac7 100644 --- a/invokeai/frontend/webv2/src/workbench/image-map/imageMap.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-map/imageMap.test.ts @@ -33,6 +33,8 @@ import { refreshImageIndexStatus, refreshImageMapPoints, setClusterEps, + attachImageMapViewer, + setImageMapMediaKind, } from './imageMapStore'; import { ALL_POINTS_TRACE, @@ -123,6 +125,18 @@ describe('image map api', () => { ); }); + it('sends media_kind only when the map is restricted to one kind', async () => { + mocks.apiFetchJson.mockResolvedValue({ ...BACKEND_RESPONSE, points: [] }); + + await fetchImageMapPoints({ mediaKind: 'video' }); + await fetchImageMapPoints(); + + expect(mocks.apiFetchJson.mock.calls.map((call) => call[0])).toEqual([ + '/api/v1/image_map/points?include_videos=true&media_kind=video', + '/api/v1/image_map/points?include_videos=true', + ]); + }); + it('posts refresh requests', async () => { mocks.apiFetchJson.mockResolvedValue({ enqueued: true }); @@ -220,6 +234,107 @@ describe('image map store', () => { expect(mocks.apiFetchJson.mock.calls.length).toBe(before); }); + it('drops points fetched for the previous media kind and labels the new set with the same kind', async () => { + const VIDEO_RESPONSE = { + ...BACKEND_RESPONSE, + point_count: 1, + points: [{ cluster: 0, image_name: 'clip.mp4', kind: 'video', x: 2, y: -1 }], + visible_hash: 'hash-videos', + }; + let resolveStale: (value: unknown) => void = () => {}; + const detachViewer = attachImageMapViewer(); + mockPointsWithForeignLabels(); + await refreshImageMapPoints(); + mocks.apiFetchJson.mockReset(); + mocks.apiFetchJson.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/image_map/cluster_labels')) { + return Promise.resolve(FOREIGN_LABELS_RESPONSE); + } + + return url.includes('media_kind=video') + ? Promise.resolve(VIDEO_RESPONSE) + : new Promise((resolve) => { + resolveStale = resolve; + }); + }); + + // A socket-driven refresh for both kinds is in flight when the filter changes. + const staleRefresh = refreshImageMapPoints(); + setImageMapMediaKind('video'); + resolveStale(BACKEND_RESPONSE); + await staleRefresh; + await vi.waitFor(() => expect(imageMapStore.getSnapshot().data?.visibleHash).toBe('hash-videos')); + + const urls = mocks.apiFetchJson.mock.calls.map((call) => String(call[0])); + expect(urls.filter((url) => url.startsWith('/api/v1/image_map/points'))).toEqual([ + '/api/v1/image_map/points?include_videos=true', + '/api/v1/image_map/points?include_videos=true&media_kind=video', + ]); + expect(urls.filter((url) => url.startsWith('/api/v1/image_map/cluster_labels'))).toEqual([ + '/api/v1/image_map/cluster_labels?include_videos=true&media_kind=video&eps=0.42', + ]); + expect(imageMapStore.getSnapshot().data?.points.map((point) => point.key)).toEqual(['video:clip.mp4']); + + detachViewer(); + imageMapStore.patchSnapshot({ data: null, loadState: 'idle' }); + setImageMapMediaKind(undefined); + }); + + it("takes the previous kind's points off a watched map at once and refetches the new kind", async () => { + const detachViewer = attachImageMapViewer(); + mockPointsWithForeignLabels(); + await refreshImageMapPoints(); + mocks.apiFetchJson.mockClear(); + + setImageMapMediaKind('image'); + + // Nothing of the previous set is left to draw or click while the new one loads. + expect(imageMapStore.getSnapshot().data).toBeNull(); + expect(imageMapStore.getSnapshot().loadState).toBe('loading'); + expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/image_map/points?include_videos=true&media_kind=image'); + + await vi.waitFor(() => expect(imageMapStore.getSnapshot().loadState).toBe('loaded')); + detachViewer(); + imageMapStore.patchSnapshot({ data: null, loadState: 'idle' }); + setImageMapMediaKind(undefined); + }); + + it('leaves an unwatched map idle until it is next opened, then loads the new kind', async () => { + mockPointsWithForeignLabels(); + await refreshImageMapPoints(); + mocks.apiFetchJson.mockClear(); + + setImageMapMediaKind('video'); + + expect(imageMapStore.getSnapshot()).toMatchObject({ data: null, loadState: 'idle' }); + expect(mocks.apiFetchJson).not.toHaveBeenCalled(); + + ensureImageMapLoaded(); + + expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/image_map/points?include_videos=true&media_kind=video'); + await vi.waitFor(() => expect(imageMapStore.getSnapshot().loadState).toBe('loaded')); + imageMapStore.patchSnapshot({ data: null, loadState: 'idle' }); + setImageMapMediaKind(undefined); + }); + + it('keeps a status diagnosis already under way when the kind changes with no points in flight', async () => { + let resolveStatus: (value: unknown) => void = () => {}; + mocks.apiFetchJson.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }) + ); + + refreshImageIndexStatus(); + setImageMapMediaKind('video'); + resolveStatus({ enabled: false, index: null, model_name: 'encoder', projection: { state: 'model_missing' } }); + + await vi.waitFor(() => expect(imageMapStore.getSnapshot().data?.state).toBe('model_missing')); + imageMapStore.patchSnapshot({ data: null, loadState: 'idle' }); + setImageMapMediaKind(undefined); + }); + it('loads points into the snapshot', async () => { mockPointsWithForeignLabels(); diff --git a/invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts b/invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts index 6102198ae0c..22799cf7820 100644 --- a/invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts +++ b/invokeai/frontend/webv2/src/workbench/image-map/imageMapSettings.ts @@ -24,3 +24,6 @@ export const getImageMapClusterEps = (values: Record): number | // stuck on an error with no way back through the UI. return typeof raw === 'number' && raw >= MIN_CLUSTER_EPS && raw <= MAX_CLUSTER_EPS ? raw : null; }; + +/** The header's media-type trigger; one Image Map exists at a time, so its view can hand focus there by id. */ +export const IMAGE_MAP_MEDIA_FILTER_TRIGGER_ID = 'image-map-media-filter-trigger'; diff --git a/invokeai/frontend/webv2/src/workbench/image-map/imageMapStore.ts b/invokeai/frontend/webv2/src/workbench/image-map/imageMapStore.ts index 4e6dbd19e04..719ab6f50c5 100644 --- a/invokeai/frontend/webv2/src/workbench/image-map/imageMapStore.ts +++ b/invokeai/frontend/webv2/src/workbench/image-map/imageMapStore.ts @@ -1,3 +1,5 @@ +import type { GalleryItemKind } from '@features/gallery/contracts'; + import { captureAccountScope, isAccountScopeCurrent, @@ -78,6 +80,9 @@ registerAccountOwnedResource({ // account: the widget pushes the new one down when it mounts, but a // socket-driven refresh could beat it there. clusterEps = null; + // Per-project like the strength; the runtime pushes the next account's filter when its project loads. + mediaKind = undefined; + pointsMediaKind = undefined; // Retire label requests so old-account completion/failure cannot update the new account. labelsSequence += 1; statusInflight = null; @@ -117,6 +122,70 @@ export const setClusterEps = (eps: number | null): void => { void refreshImageMapPoints(); }; +/** + * The media kind the map shows, or undefined for both: the active project's Gallery media filter. Pushed down by the + * data runtime, like the strength, so every refresh path fetches the same set. + */ +let mediaKind: GalleryItemKind | undefined; +/** The media kind the published points were fetched with; their labels must be requested with the same one. */ +let pointsMediaKind: GalleryItemKind | undefined; + +/** Mounted map views. A change nobody is watching waits for the next `ensureImageMapLoaded` instead of fetching. */ +let viewerCount = 0; + +/** Mount registration for a map view; the returned disposer ends it. */ +export const attachImageMapViewer = (): (() => void) => { + viewerCount += 1; + let attached = true; + + return () => { + if (attached) { + attached = false; + viewerCount -= 1; + } + }; +}; + +export const setImageMapMediaKind = (kind: GalleryItemKind | undefined): void => { + if (kind === mediaKind) { + return; + } + + mediaKind = kind; + // Labels describe the previous set's cluster ids; retire any request still in flight with them. + labelsSequence += 1; + + // A points response still in flight describes the previous set: the revision bump drops it. With none in flight + // the revision stays, so an unrelated status diagnosis already under way still lands. + if (inflight) { + mapRevision += 1; + } + + const { data, loadState } = imageMapStore.getSnapshot(); + + // Nothing fetched yet: the first fetch carries the new kind on its own. + if (data === null && loadState === 'idle') { + return; + } + + // The previous kind's points must not stay on screen or clickable: a click reveals its item, and revealing an item + // the new filter hides resets the filter to both kinds, undoing the choice. Back to idle, the next + // `ensureImageMapLoaded` refetches if nothing does sooner. + imageMapStore.patchSnapshot({ + clusterLabels: null, + clusterLabelsEps: null, + clusterLabelsHash: null, + data: null, + error: null, + loadState: 'idle', + renderError: null, + }); + + if (viewerCount > 0) { + void refreshImageMapPoints(); + } +}; + export const refreshImageMapPoints = (): Promise => { if (inflight) { // Coalesce mid-flight refresh requests into one rerun after settling instead of swallowing them through @@ -130,13 +199,18 @@ export const refreshImageMapPoints = (): Promise => { const revision = mapRevision; imageMapStore.patchSnapshot({ loadState: 'loading' }); - const refresh = fetchImageMapPoints(clusterEps !== null ? { eps: clusterEps } : undefined) + const requestMediaKind = mediaKind; + const refresh = fetchImageMapPoints({ + ...(clusterEps !== null ? { eps: clusterEps } : {}), + mediaKind: requestMediaKind, + }) .then((data) => { if (!isAccountScopeCurrent(owner) || revision !== mapRevision) { return; } mapRevision += 1; + pointsMediaKind = requestMediaKind; // Clear renderError on retry so fresh points receive a new WebGL draw attempt. imageMapStore.patchSnapshot({ data, error: null, loadState: 'loaded', renderError: null }); refreshClusterLabels(data); @@ -245,7 +319,10 @@ const isRetryableLabelsFailure = (error: unknown): boolean => { const attemptClusterLabels = (sequence: number, data: ImageMapPoints, attempt: number): void => { // Reuse the points' effective eps, but still require visibleHash equality because cluster ids can drift with the // visible set. - void fetchImageMapClusterLabels(data.clusterEps !== null ? { eps: data.clusterEps } : undefined) + void fetchImageMapClusterLabels({ + ...(data.clusterEps !== null ? { eps: data.clusterEps } : {}), + mediaKind: pointsMediaKind, + }) .then((response) => { const current = imageMapStore.getSnapshot(); @@ -356,7 +433,7 @@ export const refreshImageIndexStatus = (): void => { const sequence = indexEventSequence; const revision = mapRevision; - const request: Promise = fetchImageMapStatus() + const request: Promise = fetchImageMapStatus(mediaKind) .then((status) => { if (!isAccountScopeCurrent(owner)) { return; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.test.ts index a6d79515ef2..bf2c2945dd0 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.test.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + fetchImageMapPoints: vi.fn(), getAuthSession: vi.fn(() => ({ user: null }) as { user: { user_id: string } | null }), onConnectionChange: vi.fn(), refreshImageMapPoints: vi.fn(), @@ -16,16 +17,27 @@ vi.mock('@platform/transport/socketHub', () => ({ }, })); +// A media-kind change refreshes through the store's own (unmocked) refresh, which reads points here. +vi.mock('@workbench/image-map/api', () => ({ + fetchImageMapClusterLabels: vi.fn(), + fetchImageMapPoints: mocks.fetchImageMapPoints, + fetchImageMapStatus: vi.fn(() => new Promise(() => {})), +})); + vi.mock('@workbench/image-map/imageMapStore', async (importOriginal) => { const original = (await importOriginal()) as object; return { ...original, refreshImageMapPoints: mocks.refreshImageMapPoints }; }); -import { imageMapStore } from '@workbench/image-map/imageMapStore'; +import type { GalleryMediaFilter } from '@features/gallery/contracts'; + +import { attachImageMapViewer, imageMapStore, setImageMapMediaKind } from '@workbench/image-map/imageMapStore'; import { attachImageMapDataRuntime } from './ImageMapDataRuntime'; +const BOTH_KINDS = { getMediaFilter: (): GalleryMediaFilter => 'all', subscribe: () => () => {} }; + const getHandler = (event: string): ((payload?: unknown) => void) => { const call = mocks.socketOn.mock.calls.find(([name]) => name === event); expect(call, `no listener for ${event}`).toBeDefined(); @@ -34,7 +46,24 @@ const getHandler = (event: string): ((payload?: unknown) => void) => { }; describe('attachImageMapDataRuntime', () => { + afterEach(() => { + // Back to both kinds while idle, so the reset itself fetches nothing. + imageMapStore.patchSnapshot({ loadState: 'idle' }); + setImageMapMediaKind(undefined); + }); + beforeEach(() => { + mocks.fetchImageMapPoints.mockReset().mockResolvedValue({ + clusterEps: null, + modelId: null, + modelName: null, + pointCount: 0, + points: [], + stale: false, + state: 'empty', + updatedAt: null, + visibleHash: null, + }); mocks.socketOn.mockReset().mockReturnValue(() => {}); mocks.onConnectionChange.mockReset().mockReturnValue(() => {}); mocks.refreshImageMapPoints.mockReset(); @@ -53,7 +82,7 @@ describe('attachImageMapDataRuntime', () => { }); it('refreshes on projection-ready only after the map has been loaded once', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onReady = getHandler('image_map_projection_ready'); onReady(); @@ -67,7 +96,7 @@ describe('attachImageMapDataRuntime', () => { }); it('stores index progress counts from status events', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); getHandler('image_index_status')({ embedded: 3, failed: 1, pending: 2, total: 5 }); @@ -76,7 +105,7 @@ describe('attachImageMapDataRuntime', () => { }); it('defaults the given-up count for a server that predates the field', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); getHandler('image_index_status')({ embedded: 3, pending: 2, total: 5 }); @@ -85,7 +114,7 @@ describe('attachImageMapDataRuntime', () => { }); it('leaves a failed canvas alone instead of remounting it once per event', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); // Refresh must not repeatedly clear a WebGL failure and remount the broken plot during backfill. imageMapStore.patchSnapshot({ loadState: 'loaded', renderError: 'The map failed to render.' }); @@ -103,7 +132,7 @@ describe('attachImageMapDataRuntime', () => { }); it('does not queue a duplicate fetch for an event during the first load', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); // Ignore ordinary pokes during first loading to avoid duplicate full fetches; newly embedded items may wait // for a later event/manual refresh. @@ -115,7 +144,7 @@ describe('attachImageMapDataRuntime', () => { }); it('refetches for a projection ready during the first load', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onReady = getHandler('image_map_projection_ready'); // Queue one rerun when recompute overtakes its triggering fetch so stale points and mismatched labels do not @@ -128,7 +157,7 @@ describe('attachImageMapDataRuntime', () => { }); it('refetches for a projection ready after a failed load', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onReady = getHandler('image_map_projection_ready'); // loadState 'error' with no fetch in flight (the first load failed): the @@ -142,7 +171,7 @@ describe('attachImageMapDataRuntime', () => { }); it('still ignores a foreign projection ready during the first load', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onReady = getHandler('image_map_projection_ready'); mocks.getAuthSession.mockReturnValue({ user: { user_id: 'me' } }); @@ -155,7 +184,7 @@ describe('attachImageMapDataRuntime', () => { }); it('leaves a failed canvas alone for a projection ready during the first load', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onReady = getHandler('image_map_projection_ready'); // A retry in flight after a WebGL failure: the canvas stays failed until @@ -169,7 +198,7 @@ describe('attachImageMapDataRuntime', () => { }); it('ignores a projection recomputed for a different user', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onReady = getHandler('image_map_projection_ready'); mocks.getAuthSession.mockReturnValue({ user: { user_id: 'me' } }); @@ -191,7 +220,7 @@ describe('attachImageMapDataRuntime', () => { }); it('refreshes a loaded map when a status event reports the index quiescent', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onStatus = getHandler('image_index_status'); // Not yet loaded: counts are stored but nothing refetches. @@ -210,7 +239,7 @@ describe('attachImageMapDataRuntime', () => { }); it('refreshes a loaded map on the per-user index-updated poke', () => { - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onUpdated = getHandler('image_index_updated'); onUpdated({ user_id: 'u1' }); @@ -229,7 +258,7 @@ describe('attachImageMapDataRuntime', () => { mocks.socketOn.mockReturnValue(detachSocket); mocks.onConnectionChange.mockReturnValue(detachConnection); - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onConnection = mocks.onConnectionChange.mock.calls[0][0]; imageMapStore.patchSnapshot({ loadState: 'error' }); @@ -251,7 +280,7 @@ describe('attachImageMapDataRuntime', () => { imageMapStore.patchSnapshot({ loadState: 'loaded' }); // Treat synchronous connection replay as baseline, not reconnect, on editor remount. - const detach = attachImageMapDataRuntime(); + const detach = attachImageMapDataRuntime(BOTH_KINDS); const onConnection = mocks.onConnectionChange.mock.calls[0][0]; onConnection('connected'); @@ -264,4 +293,42 @@ describe('attachImageMapDataRuntime', () => { detach(); }); + it('refetches a loaded map exactly once per change of the shared media filter', async () => { + const listeners = new Set<() => void>(); + let mediaFilter: GalleryMediaFilter = 'all'; + const notify = () => listeners.forEach((listener) => listener()); + const detachViewer = attachImageMapViewer(); + imageMapStore.patchSnapshot({ loadState: 'loaded' }); + + const detach = attachImageMapDataRuntime({ + getMediaFilter: () => mediaFilter, + subscribe: (listener) => { + listeners.add(listener); + + return () => listeners.delete(listener); + }, + }); + + // Attaching to the filter already in force, and unrelated Workbench changes, fetch nothing. + notify(); + expect(mocks.fetchImageMapPoints).not.toHaveBeenCalled(); + + mediaFilter = 'video'; + notify(); + notify(); + await vi.waitFor(() => expect(mocks.fetchImageMapPoints).toHaveBeenCalledTimes(1)); + expect(mocks.fetchImageMapPoints).toHaveBeenLastCalledWith({ mediaKind: 'video' }); + + mediaFilter = 'all'; + notify(); + await vi.waitFor(() => expect(mocks.fetchImageMapPoints).toHaveBeenCalledTimes(2)); + expect(mocks.fetchImageMapPoints).toHaveBeenLastCalledWith({ mediaKind: undefined }); + + detach(); + mediaFilter = 'image'; + notify(); + expect(listeners.size).toBe(0); + expect(mocks.fetchImageMapPoints).toHaveBeenCalledTimes(2); + detachViewer(); + }); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.tsx index 63c6a02709e..158436c46ad 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapDataRuntime.tsx @@ -1,5 +1,7 @@ +import type { GalleryMediaFilter } from '@features/gallery/contracts'; import type { ImageIndexStatusEvent, ImageMapProjectionReadyEvent } from '@workbench/image-map/events'; +import { getGalleryMediaFilter, toGalleryMediaKind } from '@features/gallery/contracts'; import { getAuthSession } from '@features/identity'; import { useMountEffect } from '@platform/react/useMountEffect'; import { socketHub } from '@platform/transport/socketHub'; @@ -8,7 +10,10 @@ import { recordImageIndexStatus, refreshImageIndexStatus, refreshImageMapPoints, + setImageMapMediaKind, } from '@workbench/image-map/imageMapStore'; +import { getProjectWidgetValues } from '@workbench/widgetState'; +import { useWorkbenchQueries, useWorkbenchSubscription } from '@workbench/WorkbenchContext'; /** * Refresh loaded maps on projection events and reconnects; feed footer counts without fetching for sessions that @@ -28,7 +33,20 @@ const isForAnotherUser = (userId: unknown): boolean => { return typeof userId === 'string' && typeof sessionUserId === 'string' && userId !== sessionUserId; }; -export const attachImageMapDataRuntime = (): (() => void) => { +/** The active project's Gallery media filter, which the map shares, and a subscription to its changes. */ +export interface ImageMapMediaFilterSource { + getMediaFilter: () => GalleryMediaFilter; + subscribe: (listener: () => void) => () => void; +} + +export const attachImageMapDataRuntime = (mediaFilterSource: ImageMapMediaFilterSource): (() => void) => { + // The store ignores an unchanged kind, so every Workbench change may push; only an actual change refetches. + const syncMediaKind = () => setImageMapMediaKind(toGalleryMediaKind(mediaFilterSource.getMediaFilter())); + + syncMediaKind(); + + const detachMediaFilter = mediaFilterSource.subscribe(syncMediaKind); + // The widget host is mounted whenever the image-map widget is *available*, // not when it is open, so anything unconditional here bills a user who never // looks at the map. @@ -120,12 +138,22 @@ export const attachImageMapDataRuntime = (): (() => void) => { detach(); } detachConnection(); + detachMediaFilter(); }; }; /** React is only the idempotent lifecycle adapter for the non-React runtime. */ export const ImageMapDataRuntime = () => { - useMountEffect(attachImageMapDataRuntime); + const { getSnapshot } = useWorkbenchQueries(); + const subscribe = useWorkbenchSubscription(); + + // Both are the store's stable read-model ports, so the mount-time closure stays current. + useMountEffect(() => + attachImageMapDataRuntime({ + getMediaFilter: () => getGalleryMediaFilter(getProjectWidgetValues(getSnapshot().activeProject, 'gallery')), + subscribe, + }) + ); return null; }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.browser.test.tsx new file mode 100644 index 00000000000..06c30985d74 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.browser.test.tsx @@ -0,0 +1,105 @@ +import type { WidgetInstanceRuntimeMeta, WidgetRuntimeApi } from '@workbench/widgetContracts'; +/* oxlint-disable react-perf/jsx-no-jsx-as-prop */ +import type * as workbenchContext from '@workbench/WorkbenchContext'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { system } from '@theme/system'; +import { WidgetHeaderActionsGroup } from '@workbench/widget-frame/WidgetFrames'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; + +const workbench = vi.hoisted(() => ({ + galleryValues: {} as Record, + updateSettings: vi.fn(), +})); + +vi.mock('@workbench/WorkbenchContext', async (importOriginal) => ({ + ...(await importOriginal()), + useActiveProjectId: () => 'project-1', + useActiveProjectSelector: (selector: (project: unknown) => unknown) => + selector({ id: 'project-1', widgetInstances: {}, widgetRegions: {} }), + useWidgetValuesSelector: (widgetId: string, select: (values: Record) => unknown) => + select(widgetId === 'gallery' ? workbench.galleryValues : {}), + useWorkbenchCommands: () => ({ + gallery: { updateSettings: workbench.updateSettings }, + widgets: { float: vi.fn(), patchValues: vi.fn() }, + }), + useWorkbenchQueries: () => ({ getProject: () => null, isActiveProject: () => true }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ i18n: { language: 'en' }, t: (key: string) => key }), +})); + +import { ImageMapHeaderActions } from './ImageMapHeaderActions'; +import { imageMapWidgetManifest } from './manifest'; + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const instance = { id: 'image-map-1', typeId: 'image-map' } as unknown as WidgetInstanceRuntimeMeta; +const runtime = {} as WidgetRuntimeApi; + +const renderHeader = async () => { + await act(() => + root?.render( + + + } + instance={instance} + manifest={imageMapWidgetManifest} + region="right" + runtime={runtime} + /> + + ) + ); +}; + +beforeEach(() => { + workbench.galleryValues = {}; + workbench.updateSettings.mockReset(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + host = null; + root = null; +}); + +describe('image map header actions', () => { + it('puts the shared media filter directly left of the settings button and writes the Gallery setting', async () => { + workbench.galleryValues = { mediaFilter: 'image' }; + await renderHeader(); + + const buttons = [...(host?.querySelectorAll('button') ?? [])]; + const filterIndex = buttons.findIndex((button) => button.hasAttribute('data-gallery-media-filter-menu')); + const settingsIndex = buttons.findIndex((button) => button.getAttribute('aria-label') === 'widgets.settingsLabel'); + + expect(filterIndex, 'media filter did not render in the header').toBeGreaterThanOrEqual(0); + expect(buttons[filterIndex]?.getAttribute('data-media-filter')).toBe('image'); + // The view's "Show all media" hands focus to this id. + expect(buttons[filterIndex]?.id).toBe('image-map-media-filter-trigger'); + expect(settingsIndex, 'settings button did not render').toBe(filterIndex + 1); + + buttons[filterIndex]?.focus(); + await userEvent.keyboard('{Enter}'); + await page.getByRole('menuitemradio', { name: 'widgets.gallery.mediaFilterVideos' }).click(); + + expect(workbench.updateSettings).toHaveBeenCalledExactlyOnceWith({ mediaFilter: 'video' }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx index 00783669e4a..9e47a7bb219 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapHeaderActions.tsx @@ -1,19 +1,34 @@ +import type { GalleryMediaFilter } from '@features/gallery/contracts'; import type { WidgetViewProps } from '@workbench/widgetContracts'; +import { getGalleryMediaFilter, getGalleryMediaFilterMenuProps } from '@features/gallery/contracts'; import { ToggleIconButton } from '@platform/ui'; -import { getImageMapClickSelectsCluster, getImageMapShowClusterLabels } from '@workbench/image-map/imageMapSettings'; +import { IconRadioMenu } from '@platform/ui/Menu'; +import { + getImageMapClickSelectsCluster, + getImageMapShowClusterLabels, + IMAGE_MAP_MEDIA_FILTER_TRIGGER_ID, +} from '@workbench/image-map/imageMapSettings'; import { useWidgetValuesSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; import { GroupIcon, TagsIcon } from 'lucide-react'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; export const ImageMapHeaderActions = (_props: WidgetViewProps) => { - const { widgets } = useWorkbenchCommands(); + const { t } = useTranslation(); + const { gallery, widgets } = useWorkbenchCommands(); + // The Gallery's per-project setting: the map shows what the Gallery lists. + const mediaFilter = useWidgetValuesSelector('gallery', getGalleryMediaFilter); const clickSelectsCluster = useWidgetValuesSelector('image-map', getImageMapClickSelectsCluster); const showClusterLabels = useWidgetValuesSelector('image-map', getImageMapShowClusterLabels); const handleToggleClusterMode = useCallback( (checked: boolean) => widgets.patchValues('image-map', { clickSelectsCluster: checked }), [widgets] ); + const handleMediaFilterChange = useCallback( + (nextMediaFilter: GalleryMediaFilter) => gallery.updateSettings({ mediaFilter: nextMediaFilter }), + [gallery] + ); const handleToggleLabels = useCallback( (checked: boolean) => widgets.patchValues('image-map', { showClusterLabels: checked }), [widgets] @@ -37,6 +52,13 @@ export const ImageMapHeaderActions = (_props: WidgetViewProps) => { } onCheckedChange={handleToggleClusterMode} /> + {/* Last, so it sits directly beside the settings button the frame adds after the header actions. */} + ); }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.browser.test.tsx index 14ee12aa1f6..08e79a78012 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.browser.test.tsx @@ -13,6 +13,7 @@ import { page } from 'vitest/browser'; const workbench = vi.hoisted(() => ({ galleryValues: {} as Record, patchValues: vi.fn(), + updateGallerySettings: vi.fn(), })); const recovery = vi.hoisted(() => ({ status: vi.fn(), points: vi.fn() })); @@ -28,7 +29,10 @@ vi.mock('@workbench/WorkbenchContext', () => ({ // setting that is a number or null makes the widget act on a value it could never be given in production. useWidgetValuesSelector: (widgetId: string, select: (values: Record) => unknown) => select(widgetId === 'gallery' ? workbench.galleryValues : {}), - useWorkbenchCommands: () => ({ widgets: { patchValues: workbench.patchValues } }), + useWorkbenchCommands: () => ({ + gallery: { updateSettings: workbench.updateGallerySettings }, + widgets: { patchValues: workbench.patchValues }, + }), useWorkbenchQueries: () => ({ getSnapshot: () => ({ activeProject: {} }) }), })); vi.mock('@workbench/widgetState', async (importOriginal) => ({ @@ -113,11 +117,18 @@ vi.mock('@workbench/image-map/imageMapStore', async (importOriginal) => { refreshImageIndexStatus: vi.fn(), refreshImageMapPoints: vi.fn(), setClusterLabelsEnabled: vi.fn(), + setImageMapMediaKind: vi.fn(), }; }); import { registerImageCluster } from '@features/gallery/contracts'; -import { imageMapStore, refreshImageIndexStatus, refreshImageMapPoints } from '@workbench/image-map/imageMapStore'; +import { + ensureImageMapLoaded, + imageMapStore, + refreshImageIndexStatus, + refreshImageMapPoints, + setImageMapMediaKind, +} from '@workbench/image-map/imageMapStore'; import { ImageMapWidgetView } from './ImageMapWidgetView'; @@ -258,6 +269,73 @@ describe('Image Map unavailable states', () => { ); }); +describe('Image Map media filter', () => { + it('says the shared media filter emptied the map and offers to show both kinds', async () => { + workbench.galleryValues = { mediaFilter: 'video' }; + imageMapStore.setSnapshot({ + clusterLabels: null, + clusterLabelsEps: null, + clusterLabelsHash: null, + data: { ...dataFor('disabled'), state: 'empty' }, + error: null, + indexCounts: null, + indexUpdatedAt: null, + loadState: 'loaded', + renderError: null, + }); + + await act(() => + root?.render( + + + + ) + ); + + expect(host?.textContent).toContain('No videos to map'); + expect(host?.textContent).not.toContain('Nothing to map yet'); + + // The header's trigger, which the frame renders outside the view. + const trigger = document.createElement('button'); + trigger.id = 'image-map-media-filter-trigger'; + document.body.append(trigger); + + await page.getByRole('button', { name: 'Show all media' }).click(); + + expect(workbench.updateGallerySettings).toHaveBeenCalledExactlyOnceWith({ mediaFilter: 'all' }); + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); + + it('pushes the shared media filter into the store before the first load', async () => { + workbench.galleryValues = { mediaFilter: 'video' }; + imageMapStore.setSnapshot({ + clusterLabels: null, + clusterLabelsEps: null, + clusterLabelsHash: null, + data: null, + error: null, + indexCounts: null, + indexUpdatedAt: null, + loadState: 'idle', + renderError: null, + }); + + await act(() => + root?.render( + + + + ) + ); + + expect(setImageMapMediaKind).toHaveBeenCalledWith('video'); + expect(vi.mocked(setImageMapMediaKind).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(ensureImageMapLoaded).mock.invocationCallOrder[0]! + ); + }); +}); + describe('Image Map missing-model link', () => { it('replaces stalled indexing with the install link on a status poll and recovers after reinstall', async () => { const actual = await vi.importActual('@workbench/image-map/imageMapStore'); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx index 2f5b232d679..6b810c58b85 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/ImageMapWidgetView.tsx @@ -3,6 +3,7 @@ import type { WidgetViewProps } from '@workbench/widgetContracts'; import type { ReactNode } from 'react'; import { Box, Button, Center, Link, Spinner, Stack, Text } from '@chakra-ui/react'; +import { getGalleryMediaFilter, toGalleryMediaKind } from '@features/gallery/contracts'; import { ensureStartersLoaded, getStarterModelInstallSources, @@ -15,18 +16,21 @@ import { getImageMapClickSelectsCluster, getImageMapClusterEps, getImageMapShowClusterLabels, + IMAGE_MAP_MEDIA_FILTER_TRIGGER_ID, } from '@workbench/image-map/imageMapSettings'; import { attachImageMapStatusPolling, + attachImageMapViewer, ensureImageMapLoaded, imageMapStore, refreshImageIndexStatus, refreshImageMapPoints, setClusterEps, setClusterLabelsEnabled, + setImageMapMediaKind, } from '@workbench/image-map/imageMapStore'; import { isIndexing } from '@workbench/image-map/indexProgress'; -import { useWidgetValuesSelector } from '@workbench/WorkbenchContext'; +import { useWidgetValuesSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'; import { CLEAR_CLUSTER_SELECTION_LABEL, ClusterSelectionChip } from './ClusterSelectionChip'; @@ -69,6 +73,13 @@ export const ImageMapWidgetView = ({ runtime }: WidgetViewProps) => { const clickSelectsCluster = useWidgetValuesSelector('image-map', getImageMapClickSelectsCluster); const clusterEps = useWidgetValuesSelector('image-map', getImageMapClusterEps); const showClusterLabels = useWidgetValuesSelector('image-map', getImageMapShowClusterLabels); + const mediaFilter = useWidgetValuesSelector('gallery', getGalleryMediaFilter); + const { gallery: galleryCommands } = useWorkbenchCommands(); + // The button leaves with the empty state; focus moves to the header control that owns the filter. + const handleShowAllMedia = useCallback(() => { + document.getElementById(IMAGE_MAP_MEDIA_FILTER_TRIGGER_ID)?.focus(); + galleryCommands.updateSettings({ mediaFilter: 'all' }); + }, [galleryCommands]); const clearClusterSelection = useClearClusterSelection(); // Esc while the map owns focus, the keyboard path to the chip's clear. Also @@ -105,9 +116,18 @@ export const ImageMapWidgetView = ({ runtime }: WidgetViewProps) => { }, [clusterEps]); useMountEffect(() => { + const detachViewer = attachImageMapViewer(); + + // Before the first load, like the strength: a cold start under a media filter must not cluster both kinds first. + // Later changes reach the store through the data runtime. + setImageMapMediaKind(toGalleryMediaKind(mediaFilter)); ensureImageMapLoaded(); + const detachPolling = attachImageMapStatusPolling(); - return attachImageMapStatusPolling(); + return () => { + detachPolling(); + detachViewer(); + }; }); // Pushed into the store so turning labels off stops the request, not just the @@ -270,6 +290,18 @@ export const ImageMapWidgetView = ({ runtime }: WidgetViewProps) => { ); } + // The shared media filter can empty a map that has points of the other kind. + if (mediaFilter !== 'all') { + return ( + + ); + } + return ( { 'preview-instance': { id: 'preview-instance', typeId: 'preview' }, }, }, - galleryItemFilters: [] as Array<{ boardId: string; starred?: boolean }>, + galleryItemFilters: [] as Array<{ boardId: string; mediaKind?: string; starred?: boolean }>, galleryStripFetches: [] as Array<{ boardId: string; starred?: boolean }>, galleryStripItems: [] as Array, galleryItemPageOffsets: [] as number[], @@ -1771,6 +1771,37 @@ describe('preview keyboard navigation boundary', () => { ); }); + it('walks the media kind the selection was stamped with, keeping recents of the other kind out', async () => { + const newerVideo = createVideoItem('clip-newer', '2026-07-30T12:00:00Z'); + const olderVideo = createVideoItem('clip-older', '2026-07-30T11:00:00Z'); + + mocks.project.queue.items = []; + setGalleryValues({ + // A newer local image would be the left neighbour of a listing of both kinds. + recentImages: [legacyImage('fresh-image', '2026-07-31T00:00:00.000Z', 'queue-item-done')], + selectedImage: newerVideo, + selectedImageName: 'video:clip-newer', + selectedImageQuery: { ...deepQuery, mediaKind: 'video', page: 0 }, + }); + mocks.galleryItemPages = [{ items: [newerVideo, olderVideo], total: 2 }]; + + await render(); + + expect(mocks.galleryItemFilters.length).toBeGreaterThan(0); + expect(mocks.galleryItemFilters.every((query) => query.mediaKind === 'video')).toBe(true); + + await pressArrow('ArrowLeft'); + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + + await pressArrow('ArrowRight'); + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ kind: 'video', name: 'clip-older' }), + undefined, + expect.any(Number), + true + ); + }); + it('keeps settled recents out of an infinite window anchored at a deep reveal', async () => { // Do not merge top-of-board recents into deep windows or stamp them with unrelated page positions. const deepNewer = createImageItem('deep-newer', '2026-07-20T00:00:02.000Z'); @@ -2029,6 +2060,85 @@ describe('preview keyboard navigation boundary', () => { ); }); + it('swaps back to the stamped page of a listing item whose page has not loaded yet', async () => { + // The deep page holding the selection is still in flight when the swap is pressed. + mocks.deferredPageFetches.set(720, { promise: new Promise(() => {}), resolve: () => {} }); + setGalleryValues({ + compareImage: legacyImage('compare-top', '2026-07-24T00:00:00.000Z'), + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('deep-pending', '2026-07-20T00:00:02.000Z'), + selectedImageName: 'deep-pending', + selectedImageQuery: { ...deepQuery, page: 12 }, + }); + mocks.galleryItemPages = [{ items: [createImageItem('compare-top', '2026-07-24T00:00:00.000Z')], total: 800 }]; + + await render(); + + const swap = () => + act(async () => { + registeredCommands.get('viewer.swapImages')?.(); + await Promise.resolve(); + }); + + await swap(); + await commitLastSelection(); + setGalleryValues({ compareImage: legacyImage('deep-pending', '2026-07-20T00:00:02.000Z') }); + await rerender(); + await swap(); + + expect(mocks.commands.gallery.selectItem).toHaveBeenLastCalledWith( + expect.objectContaining({ kind: 'image', name: 'deep-pending' }), + undefined, + 12, + true + ); + }); + + it('swaps back to the ranked page Preview navigated, not a page stamped under another media kind', async () => { + setGalleryValues({ + compareImage: legacyImage('compare-top', '2026-07-24T00:00:00.000Z'), + galleryPage: 0, + mediaFilter: 'image', + recentImages: [], + selectedImage: legacyImage('ranked-picked', '2026-07-25T00:00:00.000Z'), + selectedImageName: 'ranked-picked', + // Stamped on page 3 of the same search over both kinds. + selectedImageQuery: { ...deepQuery, page: 3, semanticKey: 'text:sunset' }, + semanticImageQuery: { kind: 'text', query: 'sunset' }, + }); + mocks.galleryItemPages = [ + { + items: [ + createImageItem('ranked-picked', '2026-07-25T00:00:00.000Z'), + createImageItem('compare-top', '2026-07-24T00:00:00.000Z'), + ], + total: 2, + }, + ]; + + await render(); + + const swap = () => + act(async () => { + registeredCommands.get('viewer.swapImages')?.(); + await Promise.resolve(); + }); + + await swap(); + await commitLastSelection(); + setGalleryValues({ compareImage: legacyImage('ranked-picked', '2026-07-25T00:00:00.000Z') }); + await rerender(); + await swap(); + + expect(mocks.commands.gallery.selectItem).toHaveBeenLastCalledWith( + expect.objectContaining({ kind: 'image', name: 'ranked-picked' }), + undefined, + 0, + true + ); + }); + it('does not restore a remembered page into a different listing', async () => { // Do not reuse a comparison page after changing to another board query. setGalleryValues({ @@ -2526,6 +2636,72 @@ describe('preview keyboard navigation boundary', () => { ); }); + it('follows the live media filter only while it follows the live ranking', async () => { + const galleryValues = mocks.project.widgetInstances.gallery.state.values as Record; + + // Picked under both kinds; the Gallery has since switched to images only. + galleryValues.mediaFilter = 'image'; + galleryValues.selectedImageQuery = { + boardId: 'none', + galleryView: 'images', + imageOrderDir: 'DESC', + page: 0, + paginationMode: 'infinite', + searchTerm: '', + }; + + await render(); + + // An ordinary listing keeps the stamp's kinds, so the steps it stamps describe the listing it walked. + expect(mocks.galleryItemFilters.length).toBeGreaterThan(0); + expect(mocks.galleryItemFilters.every((query) => !('mediaKind' in query))).toBe(true); + + // A ranked filmstrip follows the Gallery's ranking, and so its media kind too. + mocks.galleryItemFilters.length = 0; + galleryValues.semanticImageQuery = { kind: 'text', query: 'sunset' }; + await rerender(); + + expect(mocks.galleryItemFilters.at(-1)).toMatchObject({ + mediaKind: 'image', + semanticQuery: { kind: 'text', query: 'sunset' }, + }); + }); + + it('drops a ranked page stamped under another media kind and opens where the Gallery is', async () => { + const picked = createImageItem('picked', '2026-07-30T12:00:00Z'); + const firstVideo = createVideoItem('ranked-video-1', '2026-07-29T12:00:00Z'); + const secondVideo = createVideoItem('ranked-video-2', '2026-07-28T12:00:00Z'); + + mocks.project.queue.items = []; + // Picked on page 3 of a search over both kinds; the Gallery then switched to videos only, back on page 0. + setGalleryValues({ + galleryPage: 0, + mediaFilter: 'video', + recentImages: [], + selectedImage: picked, + selectedImageName: 'image:picked', + selectedImageQuery: { ...deepQuery, page: 3, semanticKey: 'text:sunset' }, + semanticImageQuery: { kind: 'text', query: 'sunset' }, + }); + mocks.galleryItemPages = [{ items: [firstVideo, secondVideo], total: 2 }]; + + await render(); + + expect(mocks.galleryItemPageOffsets).toContain(0); + expect(mocks.galleryItemPageOffsets).not.toContain(180); + expect(mocks.galleryItemPageOffsets).not.toContain(120); + expect(mocks.galleryItemFilters.at(-1)).toMatchObject({ mediaKind: 'video' }); + + await pressArrow('ArrowRight'); + + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'video', name: 'ranked-video-1' }), + undefined, + 0, + true + ); + }); + it('stamps the selected ranking page when the footer paginates semantic results', async () => { const filler = { ...mocks.recentImages[0], diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts index 4907588e480..84653ffaf74 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts @@ -12,6 +12,7 @@ describe('getPreviewSelectedPage', () => { galleryView: 'images' as const, imageOrderDir: 'DESC' as const, itemBoard: false, + mediaKind: null, page: 3, paginationMode: 'infinite' as const, searchTerm: '', @@ -24,6 +25,7 @@ describe('getPreviewSelectedPage', () => { getPreviewSelectedPage({ galleryPage: 0, navigationBoardId: 'none', + navigationMediaKind: null, navigationSemanticKey: 'text:sunset', selectedImageQuery, }) @@ -35,6 +37,7 @@ describe('getPreviewSelectedPage', () => { getPreviewSelectedPage({ galleryPage: 0, navigationBoardId: 'none', + navigationMediaKind: null, navigationSemanticKey: 'text:dawn', selectedImageQuery, }) @@ -44,17 +47,49 @@ describe('getPreviewSelectedPage', () => { getPreviewSelectedPage({ galleryPage: 0, navigationBoardId: 'different-board', + navigationMediaKind: null, navigationSemanticKey: 'text:sunset', selectedImageQuery, }) ).toBe(0); }); + it('reuses a ranked page only while the ranking navigates the media kind it was stamped in', () => { + const pageFor = (stamped: 'image' | 'video' | null, navigated: 'image' | 'video' | null) => + getPreviewSelectedPage({ + galleryPage: 0, + navigationBoardId: 'none', + navigationMediaKind: navigated, + navigationSemanticKey: 'text:sunset', + selectedImageQuery: { ...selectedImageQuery, mediaKind: stamped }, + }); + + expect(pageFor(null, null)).toBe(3); + expect(pageFor('video', 'video')).toBe(3); + // Another kind ranks another result set: the stamped page describes none of it. + expect(pageFor(null, 'video')).toBe(0); + expect(pageFor('video', null)).toBe(0); + expect(pageFor('image', 'video')).toBe(0); + }); + + it('keeps an ordinary listing on its stamped page, whose media kind it navigates', () => { + expect( + getPreviewSelectedPage({ + galleryPage: 0, + navigationBoardId: 'none', + navigationMediaKind: 'video', + navigationSemanticKey: '', + selectedImageQuery: { ...selectedImageQuery, mediaKind: 'video', semanticKey: null }, + }) + ).toBe(3); + }); + it('does not reinterpret a semantic result page as an ordinary board page', () => { expect( getPreviewSelectedPage({ galleryPage: 0, navigationBoardId: 'none', + navigationMediaKind: null, navigationSemanticKey: '', selectedImageQuery, }) diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx index 99c4c38719a..d8524c54a8b 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx @@ -27,6 +27,7 @@ import { normalizeGalleryImage, requestGalleryItemReveal, toGalleryItemKey, + toGalleryMediaKind, toGalleryItemRef, } from '@features/gallery/contracts'; import { galleryBoardsOptions, getGalleryListingBoardsQuery } from '@features/gallery/queries'; @@ -259,6 +260,7 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { ); const { boardItems, + getNavigatedPage, getSelectionPage, isLoadingBoard, loadOrderedRefs, @@ -279,6 +281,7 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { selectedImageQuery, selectedItem, galleryBoardId, + galleryMediaKind: toGalleryMediaKind(gallerySettings.mediaFilter) ?? null, galleryPage: getGalleryPage(galleryValues), galleryPaginationMode: gallerySettings.paginationMode, selectedItemKey, @@ -329,7 +332,8 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { swappedOutRef.current = { boardId: selectedItem.boardId, key: toGalleryItemKey(selectedItem), - page: selectedImageQuery.page, + // The page in the listing Preview navigates, which a ranked filmstrip can hold apart from the stamp's. + page: getNavigatedPage(selectedItem), queryKey: navigationQueryKey, }; if ( @@ -347,10 +351,10 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { }, [ compareImage, gallery, + getNavigatedPage, navigationQueryKey, selectGalleryItemAtPage, selectPreviewItem, - selectedImageQuery.page, selectedItem, ]); const isItemCurrent = useCallback( diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts b/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts index 2ec5e3eb6e5..88186614e18 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts @@ -1,4 +1,11 @@ -import type { GalleryImageItem, GalleryItem, GalleryItemKey, GalleryItemRef, GalleryView } from '@features/gallery'; +import type { + GalleryImageItem, + GalleryItem, + GalleryItemKey, + GalleryItemKind, + GalleryItemRef, + GalleryView, +} from '@features/gallery'; import type { GalleryItemsPage, GalleryNavigationEntry, @@ -39,14 +46,22 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' const EMPTY_PREVIEW_ITEMS: GalleryItem[] = []; +/** + * The page Preview opens on. An ordinary listing is the stamp's own, so its page holds. A ranked filmstrip follows the + * Gallery's live ranking, board and media kind; the stamped page is reused only while all three still match what it + * was stamped in, and otherwise Preview mirrors the Gallery's page. + */ export const getPreviewSelectedPage = ({ galleryPage, navigationBoardId, + navigationMediaKind, navigationSemanticKey, selectedImageQuery, }: { galleryPage: number; navigationBoardId: string; + /** The media kind Preview navigates; null lists both. */ + navigationMediaKind: GalleryItemKind | null; navigationSemanticKey: string; selectedImageQuery: ReturnType; }): number => @@ -54,7 +69,9 @@ export const getPreviewSelectedPage = ({ ? selectedImageQuery.semanticKey === null ? selectedImageQuery.page : 0 - : selectedImageQuery.boardId === navigationBoardId && selectedImageQuery.semanticKey === navigationSemanticKey + : selectedImageQuery.boardId === navigationBoardId && + selectedImageQuery.semanticKey === navigationSemanticKey && + selectedImageQuery.mediaKind === navigationMediaKind ? selectedImageQuery.page : galleryPage; @@ -88,14 +105,22 @@ const getOrderedLocalItems = ({ galleryView, items, imageOrderDir, + mediaKind, }: { boardId: string; galleryView: GalleryView; items: GalleryItem[]; imageOrderDir: 'ASC' | 'DESC'; + /** The listing's media-kind restriction; null lists both. */ + mediaKind: GalleryItemKind | null; }): GalleryItem[] => getOrderedPreviewItems( - items.filter((item) => item.boardId === boardId && getItemGalleryView(item) === galleryView), + items.filter( + (item) => + item.boardId === boardId && + getItemGalleryView(item) === galleryView && + (mediaKind === null || item.kind === mediaKind) + ), imageOrderDir, 'newest-first' ); @@ -171,6 +196,11 @@ export interface PreviewNavigationState { navigationQueryKey: string; /** The page a selection of `item` is stamped with — see the action context's `getItemSelectionPage`. */ getSelectionPage: (item: GalleryItem) => number; + /** + * The page `item` holds in the listing Preview navigates: its loaded page, else the page Preview opened on. Unlike + * `getSelectionPage`, an item not loaded yet never reads as the top of the listing. + */ + getNavigatedPage: (item: GalleryItem) => number; /** Lazily fetch the full ordered listing when an action needs an item beyond the sparse loaded pages. */ loadOrderedRefs: (signal: AbortSignal) => Promise; selectPreviewItem: (item: GalleryItem) => void; @@ -186,6 +216,7 @@ export const usePreviewNavigation = ({ progressSessions, queueItems, galleryBoardId, + galleryMediaKind, galleryPage, galleryPaginationMode, selectGalleryItem, @@ -199,6 +230,8 @@ export const usePreviewNavigation = ({ followSession: (sessionId: string) => void; /** The board the gallery grid shows; a ranked list ranks within it, as the grid does. */ galleryBoardId: string; + /** The kinds the gallery grid lists (null for both); a ranked list follows it like the board. */ + galleryMediaKind: GalleryItemKind | null; /** The page the gallery grid is on; a ranked list mirrors it (see below). */ galleryPage: number; /** The gallery's own pagination mode, likewise mirrored by a ranked list. */ @@ -227,6 +260,9 @@ export const usePreviewNavigation = ({ const navigationBoardId = navigationSemanticQuery ? galleryBoardId : selectedImageQuery.boardId; const navigationGalleryView = selectedImageQuery.galleryView; const navigationOrderDir = selectedImageQuery.imageOrderDir; + // Media kind follows the same rule as the ranking: a ranked filmstrip navigates the Gallery's live kind, a listing + // the stamp's. Host steps stamp exactly what was navigated (see the reducer's getRankingStampFields). + const navigationMediaKind = navigationSemanticQuery ? galleryMediaKind : selectedImageQuery.mediaKind; // The grid partitions: its listing is unstarred-only, with the starred // items in the strip above it, unless the starred filter is on. const navigationStarredOnly = selectedImageQuery.starredOnly; @@ -236,13 +272,14 @@ export const usePreviewNavigation = ({ const selectedPage = getPreviewSelectedPage({ galleryPage, navigationBoardId, + navigationMediaKind, navigationSemanticKey, selectedImageQuery, }); // Following live has a cursor too, so the listing loads for the step off it. const hasNavigationContext = selectedItem !== null || followedSessionId !== null; - const navigationContextKey = `${accountScope.epoch}:${followedSessionId ?? ''}:${selectedItemKey ?? ''}:${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${navigationPaginationMode}:${selectedPage}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; - const navigationQueryKey = `${accountScope.epoch}:${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${navigationPaginationMode}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; + const navigationContextKey = `${accountScope.epoch}:${followedSessionId ?? ''}:${selectedItemKey ?? ''}:${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${navigationMediaKind ?? ''}:${navigationPaginationMode}:${selectedPage}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; + const navigationQueryKey = `${accountScope.epoch}:${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${navigationMediaKind ?? ''}:${navigationPaginationMode}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; const queryClient = useQueryClient(); // Publish navigation context in layout effect so boundary-fetch continuations cannot observe new UI with a stale @@ -280,11 +317,19 @@ export const usePreviewNavigation = ({ createdFrom: selectedImageSearch.range?.from, createdTo: selectedImageSearch.range?.to, galleryView: navigationGalleryView, + ...(navigationMediaKind ? { mediaKind: navigationMediaKind } : {}), orderDir: navigationOrderDir, searchTerm: selectedImageSearch.text, ...(navigationSemanticQuery ? { semanticQuery: navigationSemanticQuery } : {}), }), - [navigationBoardId, navigationGalleryView, navigationOrderDir, navigationSemanticQuery, selectedImageSearch] + [ + navigationBoardId, + navigationGalleryView, + navigationMediaKind, + navigationOrderDir, + navigationSemanticQuery, + selectedImageSearch, + ] ); const listingFilterWithStarred = useMemo( @@ -399,6 +444,17 @@ export const usePreviewNavigation = ({ (item: GalleryItem) => getSelectionPageIn(item, boardPageResults), [boardPageResults, getSelectionPageIn] ); + const getNavigatedPage = useCallback( + (item: GalleryItem) => { + const itemKey = toGalleryItemKey(item); + const page = boardPageResults.find(({ data }) => + data.items.some((candidate) => toGalleryItemKey(candidate) === itemKey) + ); + + return page === undefined ? selectedPage : page.offset / GALLERY_PAGE_SIZE; + }, + [boardPageResults, selectedPage] + ); const selectPreviewItem = useCallback( (item: GalleryItem) => stampSelection(item, boardPageResults), [boardPageResults, stampSelection] @@ -446,8 +502,9 @@ export const usePreviewNavigation = ({ galleryView: navigationGalleryView, items: navigationLocalItems, imageOrderDir: navigationOrderDir, + mediaKind: navigationMediaKind, }), - [navigationBoardId, navigationGalleryView, navigationLocalItems, navigationOrderDir] + [navigationBoardId, navigationGalleryView, navigationLocalItems, navigationMediaKind, navigationOrderDir] ); const previewLocalBoardItems = useMemo(() => { // A recent starred since it landed has moved to the strip. @@ -988,6 +1045,7 @@ export const usePreviewNavigation = ({ navigate, neighbors, navigationQueryKey, + getNavigatedPage, getSelectionPage, position, loadOrderedRefs, diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index be7b77ad3ee..969255ae256 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -16,6 +16,7 @@ import type { Project, WorkbenchState } from '@workbench/projectContracts'; import { GALLERY_RECENT_IMAGE_LIMIT, + getGallerySelectedImageQuery, legacyGeneratedImageToGalleryItem, registerImageCluster, } from '@features/gallery/contracts'; @@ -4985,6 +4986,31 @@ describe('workbenchReducer Phase 5 generation flow', () => { expect(galleryValues.selectedImageName).toBe('image:gallery-image.png'); }); + it('stamps a routed result with the media kind the grid shows, or both kinds when the filter hides it', () => { + const routeImageUnder = (mediaFilter: 'image' | 'video') => { + let state = primeGenerate(); + + state = workbenchReducer(state, { destination: 'gallery', type: 'setInvocationDestination' }); + state = workbenchReducer(state, { settings: { mediaFilter }, type: 'updateGallerySettings' }); + state = submitGenerate(state); + + const project = getActiveProject(state); + const queueItem = project.queue.items[0]; + + state = workbenchReducer(state, { + images: [createImage('result.png', queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'routeQueueItemResults', + }); + + return getGallerySelectedImageQuery(getProjectWidgetValues(getActiveProject(state), 'gallery')).mediaKind; + }; + + expect(routeImageUnder('image')).toBe('image'); + expect(routeImageUnder('video')).toBeNull(); + }); + it('normalizes the gallery page to a non-negative integer', () => { let state = primeGenerate(); @@ -5691,6 +5717,46 @@ describe('workbenchReducer Phase 5 generation flow', () => { }); }); + it('restarts the listing at its first page when the media filter changes', () => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { page: 3, type: 'setGalleryPage' }); + state = workbenchReducer(state, { settings: { mediaFilter: 'video' }, type: 'updateGallerySettings' }); + + const values = getProjectWidgetValues(getActiveProject(state), 'gallery'); + + expect(values.mediaFilter).toBe('video'); + expect(values.galleryPage).toBe(0); + }); + + it('stamps a Gallery selection with the media kind the grid lists, and an item-board selection with none', () => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { settings: { mediaFilter: 'image' }, type: 'updateGallerySettings' }); + state = workbenchReducer(state, { item: createGalleryImageItem('listed.png'), type: 'selectGalleryItem' }); + + expect(getGallerySelectedImageQuery(getProjectWidgetValues(getActiveProject(state), 'gallery')).mediaKind).toBe( + 'image' + ); + + state = workbenchReducer(state, { + item: createGalleryImageItem('searched.png'), + navigateItemBoard: true, + type: 'selectGalleryItem', + }); + + expect(getGallerySelectedImageQuery(getProjectWidgetValues(getActiveProject(state), 'gallery')).mediaKind).toBe( + null + ); + + state = workbenchReducer(state, { settings: { mediaFilter: 'all' }, type: 'updateGallerySettings' }); + state = workbenchReducer(state, { item: createGalleryImageItem('both.png'), type: 'selectGalleryItem' }); + + expect(getGallerySelectedImageQuery(getProjectWidgetValues(getActiveProject(state), 'gallery')).mediaKind).toBe( + null + ); + }); + it('stamps a toggled selection with the page of the item that becomes primary', () => { const stamp = () => { const values = getProjectWidgetValues(getActiveProject(state), 'gallery'); @@ -5798,6 +5864,60 @@ describe('workbenchReducer Phase 5 generation flow', () => { }); }); + it('keeps the stamped media kind on an ordinary host step, whatever the filter is now', () => { + let state = createInitialWorkbenchState(); + + // Selected under both kinds; the filter then hides videos, and Preview steps onto one in the listing it walks. + state = workbenchReducer(state, { item: createGalleryImageItem('anchor.png'), type: 'selectGalleryItem' }); + state = workbenchReducer(state, { settings: { mediaFilter: 'image' }, type: 'updateGallerySettings' }); + state = workbenchReducer(state, { + item: createGalleryVideoItem('stepped.mp4'), + preserveNavigationQuery: true, + selectionPage: 4, + type: 'selectGalleryItem', + }); + + const query = getGallerySelectedImageQuery(getProjectWidgetValues(getActiveProject(state), 'gallery')); + + expect(query.mediaKind).toBeNull(); + expect(query.page).toBe(4); + + state = workbenchReducer(state, { + itemKeys: ['video:stepped.mp4', 'image:range.png'], + preserveNavigationQuery: true, + primaryItem: createGalleryImageItem('range.png'), + selectionPage: 5, + type: 'setGalleryMultiSelection', + }); + + expect(getGallerySelectedImageQuery(getProjectWidgetValues(getActiveProject(state), 'gallery')).mediaKind).toBe( + null + ); + }); + + it('stamps the live media kind on a ranked host step, with the live ranking it navigated', () => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { item: createGalleryImageItem('anchor.png'), type: 'selectGalleryItem' }); + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { mediaFilter: 'image', semanticImageQuery: { kind: 'text', query: 'sunset' } }, + widgetId: 'gallery', + }); + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked.png'), + preserveNavigationQuery: true, + selectionPage: 1, + type: 'selectGalleryItem', + }); + + expect(getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageQuery).toMatchObject({ + mediaKind: 'image', + page: 1, + semanticKey: 'text:sunset', + }); + }); + it('retires a semantic page stamp when Preview resumes ordinary listing navigation', () => { let state = createInitialWorkbenchState(); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 4687858eeb7..89aacbe4d7b 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -65,6 +65,7 @@ import { GALLERY_AUTO_ADD_FOLLOW, gallerySemanticReferenceKey, getGallerySettings, + toGalleryMediaKind, parseGallerySemanticReference, toGallerySemanticTextReference, getGalleryAutoAddBoardId, @@ -3316,6 +3317,8 @@ const getGalleryResultSelectionValues = ( ): Record => { const itemKey = toGalleryItemKey(item); const gallerySettings = getGallerySettings(galleryValues); + // Navigate the kind the grid shows when it shows the result; a filter that hides it leaves both kinds. + const mediaKind = toGalleryMediaKind(gallerySettings.mediaFilter); return { ...(item.kind === 'video' ? { compareImage: null } : {}), @@ -3327,6 +3330,7 @@ const getGalleryResultSelectionValues = ( boardId: item.boardId, galleryView: item.category === 'general' ? 'images' : 'assets', imageOrderDir: gallerySettings.imageOrderDir, + ...(mediaKind === item.kind ? { mediaKind } : {}), page: 0, paginationMode: gallerySettings.paginationMode, searchTerm: '', @@ -3834,28 +3838,40 @@ const getGallerySelectionPage = (values: Record, selectionPage: /** * A host step keeps the selection's query but follows the Gallery's active ranking, except for a selection that - * navigates its item's own board. + * navigates its item's own board. Media kind follows the same rule as Preview's navigation: a ranked step navigated + * the Gallery's live kind and stamps it; an ordinary step navigated the stamp's kind, which it keeps. */ const getRankingStampFields = ( values: Record, existingNavigationQuery: Record, semanticKey: string -): Record => - existingNavigationQuery.itemBoard === true - ? {} - : semanticKey - ? { boardId: getGallerySelectionBoardId(values), semanticKey } - : { semanticKey: null }; +): Record => { + if (existingNavigationQuery.itemBoard === true) { + return {}; + } + + return semanticKey + ? { + boardId: getGallerySelectionBoardId(values), + mediaKind: toGalleryMediaKind(getGallerySettings(values).mediaFilter) ?? null, + semanticKey, + } + : { semanticKey: null }; +}; /** The listing the Gallery shows now, which a selection made in it navigates within. */ const getCurrentGallerySelectionQuery = (values: Record, page: number): Record => { const settings = getGallerySettings(values); const semanticKey = gallerySemanticReferenceKey(parseGallerySemanticReference(values.semanticImageQuery)); + const mediaKind = toGalleryMediaKind(settings.mediaFilter); return { boardId: getGallerySelectionBoardId(values), galleryView: values.galleryView === 'assets' ? 'assets' : 'images', imageOrderDir: settings.imageOrderDir, + // Absent reads as both kinds. Item-board stamps omit it, and result stamps omit it when the filter hides the result: + // both navigate a listing of both kinds. + ...(mediaKind ? { mediaKind } : {}), page, paginationMode: settings.paginationMode, searchTerm: typeof values.searchTerm === 'string' ? values.searchTerm : '', @@ -5415,7 +5431,10 @@ export const __workbenchReducerInternal = ( ); } case 'updateGallerySettings': { - const resetsQuery = action.settings.imageOrderDir !== undefined || action.settings.paginationMode !== undefined; + const resetsQuery = + action.settings.imageOrderDir !== undefined || + action.settings.mediaFilter !== undefined || + action.settings.paginationMode !== undefined; return updateGalleryValues( state, diff --git a/tests/app/routers/test_gallery.py b/tests/app/routers/test_gallery.py index b6996234b2d..971d4575d6f 100644 --- a/tests/app/routers/test_gallery.py +++ b/tests/app/routers/test_gallery.py @@ -97,3 +97,45 @@ def test_gallery_list_endpoints_reject_non_boolean_starred( response = client.get(path, params={"starred": "maybe"}) assert response.status_code == 422 + + +@pytest.mark.parametrize( + ("path", "service_index"), + [("/api/v1/gallery/items/", 0), ("/api/v1/gallery/items/names", 1), ("/api/v1/gallery/item_names", 2)], +) +@pytest.mark.parametrize(("value", "expected"), [("image", "image"), ("video", "video"), (None, None)]) +def test_gallery_list_endpoints_forward_media_kind( + monkeypatch: Any, + mock_invoker: Invoker, + client: TestClient, + path: str, + service_index: int, + value: str | None, + expected: str | None, +) -> None: + service_calls = _prepare_gallery_router_test(monkeypatch, mock_invoker) + + response = client.get(path, params={} if value is None else {"media_kind": value}) + + assert response.status_code == 200 + assert service_calls[service_index].call_args.kwargs["media_kind"] == expected + + +@pytest.mark.parametrize( + "path", + [ + "/api/v1/gallery/items/", + "/api/v1/gallery/items/names", + "/api/v1/gallery/item_names", + "/api/v1/gallery/items/location?kind=image&name=a.png", + "/api/v1/virtual_boards/by_date/2026-05-18/item_names", + ], +) +def test_gallery_listings_reject_an_unknown_media_kind( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient, path: str +) -> None: + _prepare_gallery_router_test(monkeypatch, mock_invoker) + + response = client.get(path, params={"media_kind": "audio"}) + + assert response.status_code == 422 diff --git a/tests/app/routers/test_gallery_item_location.py b/tests/app/routers/test_gallery_item_location.py index f8cd3383ce3..224c708649e 100644 --- a/tests/app/routers/test_gallery_item_location.py +++ b/tests/app/routers/test_gallery_item_location.py @@ -77,6 +77,7 @@ def _locate( search_term=filters.get("search_term"), created_from=filters.get("created_from"), created_to=filters.get("created_to"), + media_kind=filters.get("media_kind"), ) @@ -181,3 +182,21 @@ def test_inaccessible_explicit_board_keeps_existing_forbidden_behavior( assert error.value.status_code == status.HTTP_403_FORBIDDEN assert error.value.detail == "Not authorized to access this board" + + +def test_location_with_media_kind_ranks_within_the_kind_and_hides_the_other( + enable_multiuser: Any, mock_invoker: Invoker +) -> None: + _save_image(mock_invoker, "older.png", "alice") + _save_video(mock_invoker, "newer.mp4", "alice") + _set_created_at(mock_invoker, "images", "image_name", "older.png", "2026-01-01 10:00:00") + _set_created_at(mock_invoker, "videos", "video_name", "newer.mp4", "2026-01-02 10:00:00") + + both = _locate("alice", GalleryItemKind.IMAGE, "older.png") + images_only = _locate("alice", GalleryItemKind.IMAGE, "older.png", media_kind="image") + with pytest.raises(HTTPException) as error: + _locate("alice", GalleryItemKind.IMAGE, "older.png", media_kind="video") + + assert both == GalleryItemLocation(kind=GalleryItemKind.IMAGE, name="older.png", index=1, total=2) + assert images_only == GalleryItemLocation(kind=GalleryItemKind.IMAGE, name="older.png", index=0, total=1) + assert error.value.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/app/routers/test_image_map.py b/tests/app/routers/test_image_map.py index 7fe0531e9c9..edbd2f5087f 100644 --- a/tests/app/routers/test_image_map.py +++ b/tests/app/routers/test_image_map.py @@ -2076,3 +2076,59 @@ def test_a_video_only_gallery_answers_empty_instead_of_asking_forever( # Each request is answered with a projection_ready event, which is what brings the client # back for another /points. assert image_index_service.projection_requests == [] + + +def test_media_kind_serves_one_kind_consistently_across_points_labels_and_status( + mock_invoker: Invoker, client: TestClient +) -> None: + _seed_embedded_image(mock_invoker, "a.png") + _seed_embedded_image(mock_invoker, "b.png") + _seed_embedded_video(mock_invoker, "clip.mp4") + _seed_projection( + mock_invoker, + SYSTEM_USER_ID, + [IndexedItem("image", "a.png"), IndexedItem("image", "b.png"), IndexedItem("video", "clip.mp4")], + np.array([[0.0, 0.0], [0.1, 0.1], [1.0, 1.0]], dtype=np.float32), + ) + + hashes = {} + # Images first, so a videos-only request that reused the images-only clustering would fail. + for media_kind, expected in (("image", ["a.png", "b.png"]), ("video", ["clip.mp4"])): + # media_kind decides alone: include_videos=false must not hide the videos it asks for. + params = {"media_kind": media_kind, "include_videos": False} + points = client.get("/api/v1/image_map/points", params=params).json() + labels = client.get("/api/v1/image_map/cluster_labels", params=params).json() + status_body = client.get("/api/v1/image_map/status", params=params).json() + + assert sorted(p["image_name"] for p in points["points"]) == expected + assert {p["kind"] for p in points["points"]} == {media_kind} + assert points["stale"] is False + assert labels["visible_hash"] == points["visible_hash"] + assert status_body["projection"]["point_count"] == len(expected) + hashes[media_kind] = points["visible_hash"] + + assert hashes["image"] != hashes["video"] + + +def test_media_kind_narrows_both_searches_to_one_kind( + image_index_service: FakeImageIndexService, mock_invoker: Invoker, client: TestClient +) -> None: + image_index_service.search_results = [(IndexedItem("video", "clip.mp4"), 0.9), (IndexedItem("image", "a.png"), 0.5)] + + by_text = client.get("/api/v1/image_map/search", params={"q": "cat", "media_kind": "video"}) + by_image = client.post( + "/api/v1/image_map/search_by_image", + params={"media_kind": "video"}, + files={"image": ("ref.png", _png_bytes(), "image/png")}, + ) + + assert [result["image_name"] for result in by_text.json()["results"]] == ["clip.mp4"] + assert [result["image_name"] for result in by_image.json()["results"]] == ["clip.mp4"] + assert image_index_service.search_kinds == [("video",), ("video",)] + + +@pytest.mark.parametrize("path", ["/api/v1/image_map/points", "/api/v1/image_map/status", "/api/v1/image_map/search"]) +def test_an_unknown_media_kind_is_rejected(client: TestClient, path: str) -> None: + response = client.get(path, params={"q": "cat", "media_kind": "audio"}) + + assert response.status_code == 422 diff --git a/tests/app/routers/test_virtual_boards.py b/tests/app/routers/test_virtual_boards.py index 7b885126c9c..f14abf0e5a4 100644 --- a/tests/app/routers/test_virtual_boards.py +++ b/tests/app/routers/test_virtual_boards.py @@ -176,3 +176,29 @@ def test_item_names_by_date_forwards_starred_filter(client: TestClient, user1_to assert unstarred.status_code == status.HTTP_200_OK assert [item["name"] for item in unstarred.json()["items"]] == ["u1-plain.png"] assert unstarred.json()["total_count"] == 1 + + +def test_item_names_by_date_forwards_media_kind(client: TestClient, user1_token: str, mock_invoker: Invoker): + """`media_kind` narrows a date board to one kind and reports the narrowed total.""" + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + + _save_image(mock_invoker, "u1-kind.png", user1.user_id) + _save_video(mock_invoker, "u1-kind.mp4", user1.user_id) + + headers = {"Authorization": f"Bearer {user1_token}"} + date = client.get("/api/v1/virtual_boards/by_date", headers=headers).json()[0]["date"] + + videos = client.get( + f"/api/v1/virtual_boards/by_date/{date}/item_names", params={"media_kind": "video"}, headers=headers + ) + images = client.get( + f"/api/v1/virtual_boards/by_date/{date}/item_names", params={"media_kind": "image"}, headers=headers + ) + + assert videos.status_code == status.HTTP_200_OK + assert [(item["kind"], item["name"]) for item in videos.json()["items"]] == [("video", "u1-kind.mp4")] + assert videos.json()["total_count"] == 1 + assert images.status_code == status.HTTP_200_OK + assert [(item["kind"], item["name"]) for item in images.json()["items"]] == [("image", "u1-kind.png")] + assert images.json()["total_count"] == 1 diff --git a/tests/app/services/gallery/test_gallery_default.py b/tests/app/services/gallery/test_gallery_default.py index 1e21f412ad5..b31a2d3e656 100644 --- a/tests/app/services/gallery/test_gallery_default.py +++ b/tests/app/services/gallery/test_gallery_default.py @@ -1267,3 +1267,94 @@ def test_canvas_owned_image_neither_makes_nor_covers_a_day(self, services) -> No days = services["gallery"].get_dates(user_id="alice", is_admin=False) assert [(d.date, d.cover_image_name) for d in days] == [("2026-01-05", "generated.png")] + + +class TestMediaKindFilter: + """`media_kind` narrows every listing shape alike: pages and their totals, names, and locations.""" + + @pytest.mark.parametrize( + ("media_kind", "expected"), + [ + ("image", ["starred.png", "plain.png"]), + ("video", ["starred.mp4", "plain.mp4"]), + (None, ["starred.mp4", "starred.png", "plain.mp4", "plain.png"]), + ], + ) + def test_pages_and_totals_cover_only_the_chosen_kind(self, services, media_kind, expected) -> None: + _seed_starred(services) + _start_gallery_for_item_results(services) + gallery = services["gallery"] + scope = {"user_id": "alice", "is_admin": False, "media_kind": media_kind} + + pages = [gallery.list_items(offset=offset, limit=1, **scope) for offset in range(len(expected))] + count_only = gallery.list_items(limit=0, **scope) + + assert [item.name for page in pages for item in page.items] == expected + assert {page.total for page in pages} == {len(expected)} + assert count_only.total == len(expected) + + def test_name_lists_cover_only_the_chosen_kind_with_its_starred_count(self, services) -> None: + _seed_starred(services) + gallery = services["gallery"] + + images = gallery.list_item_names(user_id="alice", is_admin=True, media_kind="image") + videos = gallery.get_item_names(user_id="alice", is_admin=False, media_kind="video") + + assert [(item.kind, item.name) for item in images.items] == [ + (GalleryItemKind.IMAGE, "bob-starred.png"), + (GalleryItemKind.IMAGE, "starred.png"), + (GalleryItemKind.IMAGE, "plain.png"), + ] + assert (images.starred_count, images.total_count) == (2, 3) + assert videos.item_names == ["starred.mp4", "plain.mp4"] + assert (videos.starred_count, videos.total_count) == (1, 2) + + def test_location_ranks_within_the_kind_and_misses_the_other_kind(self, services) -> None: + _seed_starred(services) + gallery = services["gallery"] + scope = {"user_id": "alice", "is_admin": False} + + both = gallery.get_item_location(kind=GalleryItemKind.IMAGE, name="plain.png", **scope) + images = gallery.get_item_location(kind=GalleryItemKind.IMAGE, name="plain.png", media_kind="image", **scope) + hidden = gallery.get_item_location(kind=GalleryItemKind.IMAGE, name="plain.png", media_kind="video", **scope) + + assert both is not None and (both.index, both.total) == (1, 4) + assert images is not None and (images.index, images.total) == (0, 2) + assert hidden is None + + def test_composes_with_board_starred_and_date_filters(self, services) -> None: + _seed_starred(services) + board = services["boards"].save("Board", "alice") + services["board_images"].add_image_to_board(board.board_id, "starred.png") + services["board_videos"].add_video_to_board(board.board_id, "starred.mp4") + services["board_videos"].add_video_to_board(board.board_id, "plain.mp4") + gallery = services["gallery"] + scope = {"user_id": "alice", "is_admin": False} + + on_board = gallery.list_item_names(board_id=board.board_id, media_kind="video", starred=True, **scope) + off_board = gallery.list_item_names(board_id="none", media_kind="image", **scope) + by_date = gallery.get_item_names(created_date="2026-04-02", media_kind="video", **scope) + + assert [item.name for item in on_board.items] == ["starred.mp4"] + assert [item.name for item in off_board.items] == ["plain.png"] + assert by_date.item_names == ["plain.mp4"] + for kind, name in [(GalleryItemKind.VIDEO, "starred.mp4"), (GalleryItemKind.VIDEO, "plain.mp4")]: + location = gallery.get_item_location( + kind=kind, name=name, board_id=board.board_id, media_kind="video", **scope + ) + listing = gallery.list_item_names(board_id=board.board_id, media_kind="video", starred_first=False, **scope) + assert location is not None + assert location.index == [item.name for item in listing.items].index(name) + assert location.total == listing.total_count == 2 + + def test_a_one_kind_listing_reads_only_that_kinds_table(self, services) -> None: + _save_image(services["images"], "image.png", user_id="alice") + _save_video(services["videos"], "video.mp4", user_id="alice") + + with capture_statements(services["db"]) as statements: + names = services["gallery"].get_item_names(is_admin=True, media_kind="image") + + assert names.item_names == ["image.png"] + sql = " ".join(statement for statement, _ in statements) + assert "UNION" not in sql + assert "videos" not in sql