Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/features/gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ The settings button opens a list of options.
Below these two buttons, you'll see the Search Boards text entry area. You use this to search for specific boards by the name of the board.
Next to it is the Add Board (+) button which lets you add new boards. Boards can be renamed by clicking on the name of the board under its thumbnail and typing in the new name.

### Virtual Boards by Date

In addition to the regular user-created boards, the Gallery can show **virtual boards** that group your images automatically by their creation date. Virtual boards are not stored in the database — they are computed on the fly from existing image metadata, so enabling or disabling them never moves or modifies your images.

#### Enabling Virtual Boards

Open the boards settings popover (the gear icon next to the boards search field) and toggle **Show Virtual Boards**. A new collapsible **By Date** section then appears in the boards list, with one entry per day on which images were generated (e.g. `2026-03-18`).

Each virtual board entry shows:

- a cover thumbnail (the most recent image of that day)
- the number of generated **images** on that date
- the number of uploaded **assets** on that date

Selecting a virtual board filters the gallery to show only the images from that day. Search, category filters (Images / Assets), starred-first sorting and sort direction all work the same way as on regular boards.

!!! note "Read-only"

Virtual boards are a view over your existing images. You cannot rename, delete or auto-assign to them, and images cannot be "moved into" a virtual board — they appear there automatically based on their creation date. To organize images permanently, use regular boards.

### Board Thumbnail Menu

Each board has a context menu (ctrl+click / right-click).
Expand Down
56 changes: 56 additions & 0 deletions invokeai/app/api/routers/virtual_boards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from fastapi import HTTPException, Path, Query
from fastapi.routing import APIRouter

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageNamesResult
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO

virtual_boards_router = APIRouter(prefix="/v1/virtual_boards", tags=["virtual_boards"])


@virtual_boards_router.get(
"/by_date",
operation_id="list_virtual_boards_by_date",
response_model=list[VirtualSubBoardDTO],
)
async def list_virtual_boards_by_date(
current_user: CurrentUserOrDefault,
) -> list[VirtualSubBoardDTO]:
"""Gets a list of virtual sub-boards grouped by date."""
try:
return ApiDependencies.invoker.services.image_records.get_image_dates(
user_id=current_user.user_id,
is_admin=current_user.is_admin,
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to get virtual boards by date")


@virtual_boards_router.get(
"/by_date/{date}/image_names",
operation_id="list_virtual_board_image_names_by_date",
response_model=ImageNamesResult,
)
async def list_virtual_board_image_names_by_date(
current_user: CurrentUserOrDefault,
date: str = Path(description="The ISO date string, e.g. '2026-03-18'"),
starred_first: bool = Query(default=True, description="Whether to sort starred images first"),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The sort direction"),
categories: list[ImageCategory] | None = Query(default=None, description="The categories of images to include"),
search_term: str | None = Query(default=None, description="Search term to filter images"),
) -> ImageNamesResult:
"""Gets ordered image names for a specific date."""
try:
return ApiDependencies.invoker.services.image_records.get_image_names_by_date(
date=date,
starred_first=starred_first,
order_dir=order_dir,
categories=categories,
search_term=search_term,
user_id=current_user.user_id,
is_admin=current_user.is_admin,
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to get image names for date")
2 changes: 2 additions & 0 deletions invokeai/app/api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
session_queue,
style_presets,
utilities,
virtual_boards,
workflows,
)
from invokeai.app.api.sockets import SocketIO
Expand Down Expand Up @@ -177,6 +178,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
app.include_router(images.images_router, prefix="/api")
app.include_router(boards.boards_router, prefix="/api")
app.include_router(board_images.board_images_router, prefix="/api")
app.include_router(virtual_boards.virtual_boards_router, prefix="/api")
app.include_router(model_relationships.model_relationships_router, prefix="/api")
app.include_router(app_info.app_router, prefix="/api")
app.include_router(session_queue.session_queue_router, prefix="/api")
Expand Down
24 changes: 24 additions & 0 deletions invokeai/app/services/image_records/image_records_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO


class ImageRecordStorageBase(ABC):
Expand Down Expand Up @@ -122,3 +123,26 @@ def get_image_names(
) -> ImageNamesResult:
"""Gets ordered list of image names with metadata for optimistic updates."""
pass

@abstractmethod
def get_image_dates(
self,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> list[VirtualSubBoardDTO]:
"""Gets a list of dates with image counts, grouped by DATE(created_at)."""
pass

@abstractmethod
def get_image_names_by_date(
self,
date: str,
starred_first: bool = True,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
categories: Optional[list[ImageCategory]] = None,
search_term: Optional[str] = None,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> ImageNamesResult:
"""Gets ordered list of image names for a specific date."""
pass
139 changes: 139 additions & 0 deletions invokeai/app/services/image_records/image_records_sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO


class SqliteImageRecordStorage(ImageRecordStorageBase):
Expand Down Expand Up @@ -503,3 +504,141 @@ def get_image_names(
image_names = [row[0] for row in result]

return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))

def get_image_dates(
self,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> list[VirtualSubBoardDTO]:
with self._db.transaction() as cursor:
query_conditions = ""
query_params: list[Union[int, str, bool]] = []

# Only non-intermediate images
query_conditions += """--sql
AND images.is_intermediate = 0
"""

# User isolation for non-admin users
if user_id is not None and not is_admin:
query_conditions += """--sql
AND images.user_id = ?
"""
query_params.append(user_id)

query = f"""--sql
SELECT
DATE(images.created_at) as date,
SUM(CASE WHEN images.image_category = 'general' THEN 1 ELSE 0 END) as image_count,
SUM(CASE WHEN images.image_category != 'general' THEN 1 ELSE 0 END) as asset_count,
(
SELECT i2.image_name FROM images i2
WHERE DATE(i2.created_at) = DATE(images.created_at)
AND i2.is_intermediate = 0
ORDER BY i2.created_at DESC LIMIT 1
) as cover_image_name
FROM images
WHERE 1=1
{query_conditions}
GROUP BY DATE(images.created_at)
ORDER BY date DESC;
"""

cursor.execute(query, query_params)
result = cast(list[sqlite3.Row], cursor.fetchall())

return [
VirtualSubBoardDTO(
virtual_board_id=f"by_date:{dict(row)['date']}",
board_name=dict(row)["date"],
date=dict(row)["date"],
image_count=dict(row)["image_count"],
asset_count=dict(row)["asset_count"],
cover_image_name=dict(row)["cover_image_name"],
)
for row in result
]

def get_image_names_by_date(
self,
date: str,
starred_first: bool = True,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
categories: Optional[list[ImageCategory]] = None,
search_term: Optional[str] = None,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> ImageNamesResult:
with self._db.transaction() as cursor:
query_conditions = ""
query_params: list[Union[int, str, bool]] = []

# Filter by date
query_conditions += """--sql
AND DATE(images.created_at) = ?
"""
query_params.append(date)

# Only non-intermediate images
query_conditions += """--sql
AND images.is_intermediate = 0
"""

if categories is not None:
category_strings = [c.value for c in set(categories)]
placeholders = ",".join("?" * len(category_strings))
query_conditions += f"""--sql
AND images.image_category IN ( {placeholders} )
"""
for c in category_strings:
query_params.append(c)

# User isolation for non-admin users
if user_id is not None and not is_admin:
query_conditions += """--sql
AND images.user_id = ?
"""
query_params.append(user_id)

if search_term:
query_conditions += """--sql
AND (
images.metadata LIKE ?
OR images.created_at LIKE ?
)
"""
query_params.append(f"%{search_term.lower()}%")
query_params.append(f"%{search_term.lower()}%")

# Get starred count if starred_first is enabled
starred_count = 0
if starred_first:
starred_count_query = f"""--sql
SELECT COUNT(*)
FROM images
WHERE images.starred = TRUE AND (1=1{query_conditions})
"""
cursor.execute(starred_count_query, query_params)
starred_count = cast(int, cursor.fetchone()[0])

# Get all image names with proper ordering
if starred_first:
names_query = f"""--sql
SELECT images.image_name
FROM images
WHERE 1=1{query_conditions}
ORDER BY images.starred DESC, images.created_at {order_dir.value}
"""
else:
names_query = f"""--sql
SELECT images.image_name
FROM images
WHERE 1=1{query_conditions}
ORDER BY images.created_at {order_dir.value}
"""

cursor.execute(names_query, query_params)
result = cast(list[sqlite3.Row], cursor.fetchall())
image_names = [row[0] for row in result]

return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))
Empty file.
14 changes: 14 additions & 0 deletions invokeai/app/services/virtual_boards/virtual_boards_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Optional

from pydantic import BaseModel, Field


class VirtualSubBoardDTO(BaseModel):
"""A virtual sub-board computed from image metadata, not stored in the database."""

virtual_board_id: str = Field(description="The virtual board ID, e.g. 'by_date:2026-03-18'.")
board_name: str = Field(description="The display name of the virtual sub-board, e.g. '2026-03-18'.")
date: str = Field(description="The ISO date string, e.g. '2026-03-18'.")
image_count: int = Field(description="The number of general images for this date.")
asset_count: int = Field(description="The number of asset images for this date.")
cover_image_name: Optional[str] = Field(default=None, description="The most recent image name for this date.")
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import AddBoardButton from './AddBoardButton';
import GalleryBoard from './GalleryBoard';
import NoBoardBoard from './NoBoardBoard';
import { VirtualBoardSection } from './VirtualBoardSection';

export const BoardsList = memo(() => {
const { t } = useTranslation();
Expand All @@ -40,6 +41,7 @@ export const BoardsList = memo(() => {

if (!boardSearchText.length) {
elements.push(<NoBoardBoard key="none" isSelected={selectedBoardId === 'none'} />);
elements.push(<VirtualBoardSection key="virtual-boards" />);
}

filteredBoards.forEach((board) => {
Expand Down
Loading
Loading