diff --git a/docs/src/content/docs/configuration/invokeai-yaml.mdx b/docs/src/content/docs/configuration/invokeai-yaml.mdx index a0c10ef9560..360e36ca44c 100644 --- a/docs/src/content/docs/configuration/invokeai-yaml.mdx +++ b/docs/src/content/docs/configuration/invokeai-yaml.mdx @@ -188,6 +188,37 @@ Available strategies: Changing this setting only affects newly-created images. Existing images remain in their current locations unless you run [Image Storage Maintenance](/features/image-storage-maintenance/). +#### Response Compression + +API responses (JSON, HTML, JS, CSS, SVG) are gzipped before being sent. Already-compressed responses — PNG, WebP, JPEG, MP4 — are passed through untouched, since compressing them costs CPU and returns a body no smaller than the original. + +Compression runs on the server's event loop, which means it blocks *everything else* for its duration: no other request is served and no progress event is delivered while it runs. The `gzip_compresslevel` setting controls that trade-off: + +```yaml +gzip_compresslevel: 9 # default value +``` + +| Value | Behavior | +| ------ | ---------------------------------------------------------------------------------------------- | +| `0` | No compression. Responses are sent as-is and the compression middleware is not installed. | +| `1` | Fastest compression, slightly larger output. | +| `2`–`8` | Progressively slower, marginally smaller. | +| `9` | Smallest output, by far the slowest. This is the default. | + +If the UI feels sluggish while a large library is being browsed, lowering this is one of the cheapest wins available. Measured on the image-name list of a 200,000-image library (8.48 MB of JSON): + +| Level | Time | Output | +| ----- | ------: | --------: | +| `1` | 16.4 ms | 6.1% of input | +| `6` | 36.1 ms | 5.9% of input | +| `9` | 90.2 ms | 5.7% of input | + +Level 9 spends 5.5× the event-loop time to save 0.4 percentage points of bandwidth. On a locally-served install the bandwidth is free and the stall is not, so `gzip_compresslevel: 1` is usually the better setting there — the default stays at `9` only so that upgrading does not silently change how anyone's install behaves. + +:::tip[Behind a reverse proxy] +If you serve InvokeAI through nginx, Caddy, or similar, set `gzip_compresslevel: 0` and let the proxy compress instead. The proxy does that work in its own process rather than on InvokeAI's event loop, and it avoids compressing the same bytes twice. +::: + #### Logging Several different log handler destinations are available, and multiple destinations are supported by providing a list: diff --git a/docs/src/content/docs/contributing/blocking-work-in-api-routes.md b/docs/src/content/docs/contributing/blocking-work-in-api-routes.md new file mode 100644 index 00000000000..29d9da8d3ee --- /dev/null +++ b/docs/src/content/docs/contributing/blocking-work-in-api-routes.md @@ -0,0 +1,90 @@ +--- +title: Blocking Work in API Routes +--- + +Almost every service in the backend is synchronous — the database layer, the model +manager, the file stores. The API layer in front of them is asynchronous. Getting the +boundary between the two wrong does not produce a slow endpoint; it produces a server +that stops answering entirely. + +## The rule + +**A route handler that only calls synchronous services must be declared `def`, not +`async def`.** + +```python +# Correct — Starlette runs this in a worker thread. +@gallery_router.get("/items/names") +def get_gallery_item_names(current_user: CurrentUserOrDefault) -> GalleryItemNamesResult: + return ApiDependencies.invoker.services.gallery.list_item_names(...) +``` + +```python +# Wrong — the database query runs on the event loop. +@gallery_router.get("/items/names") +async def get_gallery_item_names(current_user: CurrentUserOrDefault) -> GalleryItemNamesResult: + return ApiDependencies.invoker.services.gallery.list_item_names(...) +``` + +The same rule applies to **dependencies**, not just handlers. A dependency declared +`async def` that performs a synchronous database lookup blocks the loop on every request +that uses it. + +## Why it matters + +The server runs as a single process with a single event loop. Anything executed directly +on that loop has the whole process to itself until it returns. Blocking work on the loop +therefore does not just delay its own response — for its entire duration the process +serves **no** other HTTP request and delivers **no** socket.io event. Users do not +experience this as one slow endpoint; they experience it as the application freezing, +typically mid-generation, because progress events stop arriving too. + +The cost scales with the user's library, not with the developer's. A gallery query that +returns in milliseconds against a test database can take minutes against a multi-gigabyte +one — for example a metadata search, which has to read every row's metadata blob. + +Declaring the handler `def` makes FastAPI dispatch it to a worker thread instead, leaving +the loop free to serve everything else. + +## When `async def` is right + +Use `async def` when the body actually awaits something — streaming a response, awaiting +another async API, or coordinating tasks. If such a handler *also* performs blocking work, +that work must be wrapped explicitly: + +```python +from starlette.concurrency import run_in_threadpool + +user = await run_in_threadpool(ApiDependencies.invoker.services.users.get, user_id) +``` + +`async def` with no `await` in the body is always a mistake: it gains nothing and costs +the loop. + +## What this does not fix + +Moving work to the threadpool does not make it faster, and it does not make it parallel. +The SQLite layer uses a single connection behind a process-wide lock, so database work +remains serialized regardless of which thread requests it. The benefit is confined to — +and this is the point — keeping everything *else* responsive while it runs. + +## Testing it + +Two tests cover this, and they do different jobs. + +`tests/app/routers/test_no_blocking_async_routes.py` **enforces the rule**: it parses every +router module and fails if any route handler is `async def` without awaiting anything. This +is the one that catches a new route — a per-route test cannot, because the route does not +exist when the test is written. + +`tests/app/routers/test_event_loop_blocking.py` **proves the effect** for a few +representative routes. It stubs a service method to block synchronously, issues a request +against the route under test, and asserts that an unrelated trivial route still answers +while that request is in flight. + +Note what the second one measures: not the slow request's own duration, which the fix does +not change, but the latency of other requests during it. A benchmark of the slow endpoint +alone will show no improvement and is the wrong instrument here. + +If you call a route handler directly from a test, call it like the plain function it now is +— no `await`, no `asyncio.run`. diff --git a/docs/src/generated/settings.json b/docs/src/generated/settings.json index 2c183f55400..97b3bf56bf8 100644 --- a/docs/src/generated/settings.json +++ b/docs/src/generated/settings.json @@ -114,6 +114,17 @@ "type": "", "validation": {} }, + { + "category": "WEB", + "default": 9, + "description": "GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses.", + "env_var": "INVOKEAI_GZIP_COMPRESSLEVEL", + "literal_values": [], + "name": "gzip_compresslevel", + "required": false, + "type": "", + "validation": {} + }, { "category": "MISC FEATURES", "default": false, diff --git a/invokeai/app/api/auth_dependencies.py b/invokeai/app/api/auth_dependencies.py index 1ba768a94cd..3eb20f611fb 100644 --- a/invokeai/app/api/auth_dependencies.py +++ b/invokeai/app/api/auth_dependencies.py @@ -27,7 +27,7 @@ def _validate_token(token: str, invalid_detail: str) -> TokenData: return token_data -async def get_current_user( +def get_current_user( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], ) -> TokenData: """Get current authenticated user from Bearer token. @@ -76,7 +76,7 @@ async def get_current_user( return token_data -async def get_current_user_or_default( +def get_current_user_or_default( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], ) -> TokenData: """Get current authenticated user from Bearer token, or return a default system user if not authenticated. @@ -128,7 +128,7 @@ async def get_current_user_or_default( return token_data -async def get_current_media_user_or_default( +def get_current_media_user_or_default( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], media_token: Annotated[str | None, Cookie(alias=MEDIA_TOKEN_COOKIE)] = None, ) -> TokenData: @@ -141,7 +141,7 @@ async def get_current_media_user_or_default( return _validate_token(token, "Invalid or expired token") -async def require_admin( +def require_admin( current_user: Annotated[TokenData, Depends(get_current_user)], ) -> TokenData: """Require admin role for the current user. @@ -160,7 +160,7 @@ async def require_admin( return current_user -async def require_admin_or_default( +def require_admin_or_default( current_user: Annotated[TokenData, Depends(get_current_user_or_default)], ) -> TokenData: """Require admin role for the current user, or return default system admin in single-user mode. diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index 1546291670b..44e365df908 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -52,12 +52,12 @@ class AppVersion(BaseModel): @app_router.get("/version", operation_id="app_version", status_code=200, response_model=AppVersion) -async def get_version() -> AppVersion: +def get_version() -> AppVersion: return AppVersion(version=__version__) @app_router.get("/app_deps", operation_id="get_app_deps", status_code=200, response_model=dict[str, str]) -async def get_app_deps(current_user: CurrentUserOrDefault) -> dict[str, str]: +def get_app_deps(current_user: CurrentUserOrDefault) -> dict[str, str]: deps: dict[str, str] = {dist.metadata["Name"]: dist.version for dist in distributions()} try: cuda = getattr(getattr(torch, "version", None), "cuda", None) or "N/A" # pyright: ignore[reportAttributeAccessIssue] @@ -72,7 +72,7 @@ async def get_app_deps(current_user: CurrentUserOrDefault) -> dict[str, str]: @app_router.get("/patchmatch_status", operation_id="get_patchmatch_status", status_code=200, response_model=bool) -async def get_patchmatch_status(current_user: CurrentUserOrDefault) -> bool: +def get_patchmatch_status(current_user: CurrentUserOrDefault) -> bool: return PatchMatch.patchmatch_available() @@ -212,7 +212,7 @@ def _redact_config_secrets(config: InvokeAIAppConfig) -> InvokeAIAppConfig: status_code=200, response_model=list[GenerationDeviceOption], ) -async def get_generation_device_options(current_user: CurrentUserOrDefault) -> list[GenerationDeviceOption]: +def get_generation_device_options(current_user: CurrentUserOrDefault) -> list[GenerationDeviceOption]: """List the devices available for generation, for use with the `generation_devices` setting.""" options: list[GenerationDeviceOption] = [] if torch.cuda.is_available(): @@ -233,7 +233,7 @@ async def get_generation_device_options(current_user: CurrentUserOrDefault) -> l @app_router.get( "/runtime_config", operation_id="get_runtime_config", status_code=200, response_model=InvokeAIAppConfigWithSetFields ) -async def get_runtime_config(current_admin: AdminUserOrDefault) -> InvokeAIAppConfigWithSetFields: +def get_runtime_config(current_admin: AdminUserOrDefault) -> InvokeAIAppConfigWithSetFields: config = get_config() return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=_redact_config_secrets(config)) @@ -244,7 +244,7 @@ async def get_runtime_config(current_admin: AdminUserOrDefault) -> InvokeAIAppCo status_code=200, response_model=InvokeAIAppConfigWithSetFields, ) -async def update_runtime_config( +def update_runtime_config( _: AdminUserOrDefault, changes: UpdateAppGenerationSettingsRequest = Body(description="Writable runtime configuration changes"), ) -> InvokeAIAppConfigWithSetFields: @@ -277,7 +277,7 @@ async def update_runtime_config( status_code=200, response_model=list[ExternalProviderStatusModel], ) -async def get_external_provider_statuses(current_user: CurrentUserOrDefault) -> list[ExternalProviderStatusModel]: +def get_external_provider_statuses(current_user: CurrentUserOrDefault) -> list[ExternalProviderStatusModel]: statuses = ApiDependencies.invoker.services.external_generation.get_provider_statuses() return [status_to_model(status) for status in statuses.values()] @@ -288,7 +288,7 @@ async def get_external_provider_statuses(current_user: CurrentUserOrDefault) -> status_code=200, response_model=list[ExternalProviderConfigModel], ) -async def get_external_provider_configs(current_admin: AdminUserOrDefault) -> list[ExternalProviderConfigModel]: +def get_external_provider_configs(current_admin: AdminUserOrDefault) -> list[ExternalProviderConfigModel]: config = get_config() return [_build_external_provider_config(provider_id, config) for provider_id in EXTERNAL_PROVIDER_FIELDS] @@ -299,7 +299,7 @@ async def get_external_provider_configs(current_admin: AdminUserOrDefault) -> li status_code=200, response_model=ExternalProviderConfigModel, ) -async def set_external_provider_config( +def set_external_provider_config( _: AdminUserOrDefault, provider_id: str = Path(description="The external provider identifier"), update: ExternalProviderConfigUpdate = Body(description="External provider configuration settings"), @@ -330,7 +330,7 @@ async def set_external_provider_config( status_code=200, response_model=ExternalProviderConfigModel, ) -async def reset_external_provider_config( +def reset_external_provider_config( _: AdminUserOrDefault, provider_id: str = Path(description="The external provider identifier"), ) -> ExternalProviderConfigModel: @@ -439,7 +439,7 @@ def _remove_external_models_for_provider(provider_id: str) -> None: responses={200: {"description": "The operation was successful"}}, response_model=LogLevel, ) -async def get_log_level(current_admin: AdminUserOrDefault) -> LogLevel: +def get_log_level(current_admin: AdminUserOrDefault) -> LogLevel: """Returns the log level""" return LogLevel(ApiDependencies.invoker.services.logger.level) @@ -450,7 +450,7 @@ async def get_log_level(current_admin: AdminUserOrDefault) -> LogLevel: responses={200: {"description": "The operation was successful"}}, response_model=LogLevel, ) -async def set_log_level( +def set_log_level( current_admin: AdminUserOrDefault, level: LogLevel = Body(description="New log verbosity level"), ) -> LogLevel: @@ -464,7 +464,7 @@ async def set_log_level( operation_id="clear_invocation_cache", responses={200: {"description": "The operation was successful"}}, ) -async def clear_invocation_cache(current_admin: AdminUserOrDefault) -> None: +def clear_invocation_cache(current_admin: AdminUserOrDefault) -> None: """Clears the invocation cache""" ApiDependencies.invoker.services.invocation_cache.clear() @@ -474,7 +474,7 @@ async def clear_invocation_cache(current_admin: AdminUserOrDefault) -> None: operation_id="enable_invocation_cache", responses={200: {"description": "The operation was successful"}}, ) -async def enable_invocation_cache(current_admin: AdminUserOrDefault) -> None: +def enable_invocation_cache(current_admin: AdminUserOrDefault) -> None: """Clears the invocation cache""" ApiDependencies.invoker.services.invocation_cache.enable() @@ -484,7 +484,7 @@ async def enable_invocation_cache(current_admin: AdminUserOrDefault) -> None: operation_id="disable_invocation_cache", responses={200: {"description": "The operation was successful"}}, ) -async def disable_invocation_cache(current_admin: AdminUserOrDefault) -> None: +def disable_invocation_cache(current_admin: AdminUserOrDefault) -> None: """Clears the invocation cache""" ApiDependencies.invoker.services.invocation_cache.disable() @@ -494,6 +494,6 @@ async def disable_invocation_cache(current_admin: AdminUserOrDefault) -> None: operation_id="get_invocation_cache_status", responses={200: {"model": InvocationCacheStatus}}, ) -async def get_invocation_cache_status(current_admin: AdminUserOrDefault) -> InvocationCacheStatus: +def get_invocation_cache_status(current_admin: AdminUserOrDefault) -> InvocationCacheStatus: """Clears the invocation cache""" return ApiDependencies.invoker.services.invocation_cache.get_status() diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py index f6a767c7c8d..ec6f3146c7a 100644 --- a/invokeai/app/api/routers/auth.py +++ b/invokeai/app/api/routers/auth.py @@ -129,7 +129,7 @@ class SetupStatusResponse(BaseModel): @auth_router.get("/status", response_model=SetupStatusResponse) -async def get_setup_status() -> SetupStatusResponse: +def get_setup_status() -> SetupStatusResponse: """Check if initial administrator setup is required. Returns: @@ -163,7 +163,7 @@ async def get_setup_status() -> SetupStatusResponse: @auth_router.post("/login", response_model=LoginResponse) -async def login( +def login( login_request: Annotated[LoginRequest, Body(description="Login credentials")], request: Request, response: Response, @@ -223,7 +223,7 @@ async def login( @auth_router.post("/logout", response_model=LogoutResponse) -async def logout( +def logout( current_user: CurrentUser, request: Request, response: Response, @@ -250,7 +250,7 @@ async def logout( @auth_router.post("/media-cookie", response_model=MediaCookieResponse) -async def refresh_media_cookie( +def refresh_media_cookie( request: Request, response: Response, _current_user: CurrentUserOrDefault, @@ -297,7 +297,7 @@ async def refresh_media_cookie( @auth_router.get("/me", response_model=UserDTO) -async def get_current_user_info( +def get_current_user_info( current_user: CurrentUser, ) -> UserDTO: """Get current authenticated user's information. @@ -321,7 +321,7 @@ async def get_current_user_info( @auth_router.post("/setup", response_model=SetupResponse) -async def setup_admin( +def setup_admin( request: Annotated[SetupRequest, Body(description="Admin account details")], ) -> SetupResponse: """Set up initial administrator account. @@ -423,7 +423,7 @@ class GeneratePasswordResponse(BaseModel): @auth_router.get("/generate-password", response_model=GeneratePasswordResponse) -async def generate_password( +def generate_password( current_user: CurrentUser, ) -> GeneratePasswordResponse: """Generate a strong random password. @@ -444,7 +444,7 @@ async def generate_password( @auth_router.get("/users", response_model=list[UserDTO]) -async def list_users( +def list_users( current_user: AdminUser, ) -> list[UserDTO]: """List all users. Requires admin privileges. @@ -460,7 +460,7 @@ async def list_users( @auth_router.post("/users", response_model=UserDTO, status_code=status.HTTP_201_CREATED) -async def create_user( +def create_user( request: Annotated[AdminUserCreateRequest, Body(description="New user details")], current_user: AdminUser, ) -> UserDTO: @@ -490,7 +490,7 @@ async def create_user( @auth_router.get("/users/{user_id}", response_model=UserDTO) -async def get_user( +def get_user( user_id: Annotated[str, Path(description="User ID")], current_user: AdminUser, ) -> UserDTO: @@ -513,7 +513,7 @@ async def get_user( @auth_router.patch("/users/{user_id}", response_model=UserDTO) -async def update_user( +def update_user( user_id: Annotated[str, Path(description="User ID")], request: Annotated[AdminUserUpdateRequest, Body(description="User fields to update")], current_user: AdminUser, @@ -546,7 +546,7 @@ async def update_user( @auth_router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user( +def delete_user( user_id: Annotated[str, Path(description="User ID")], current_user: AdminUser, ) -> None: @@ -581,7 +581,7 @@ async def delete_user( @auth_router.patch("/me", response_model=UserDTO) -async def update_current_user( +def update_current_user( request: Annotated[UserProfileUpdateRequest, Body(description="Profile fields to update")], current_user: CurrentUser, ) -> UserDTO: diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index ea0273f02d6..00c5c3a9bec 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -58,7 +58,7 @@ def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefau status_code=201, response_model=AddImagesToBoardResult, ) -async def add_image_to_board( +def add_image_to_board( current_user: CurrentUserOrDefault, board_id: str = Body(description="The id of the board to add to"), image_name: str = Body(description="The name of the image to add"), @@ -93,7 +93,7 @@ async def add_image_to_board( status_code=201, response_model=RemoveImagesFromBoardResult, ) -async def remove_image_from_board( +def remove_image_from_board( current_user: CurrentUserOrDefault, image_name: str = Body(description="The name of the image to remove", embed=True), ) -> RemoveImagesFromBoardResult: @@ -129,7 +129,7 @@ async def remove_image_from_board( status_code=201, response_model=AddImagesToBoardResult, ) -async def add_images_to_board( +def add_images_to_board( current_user: CurrentUserOrDefault, board_id: str = Body(description="The id of the board to add to"), image_names: list[str] = Body(description="The names of the images to add", embed=True), @@ -183,7 +183,7 @@ async def add_images_to_board( status_code=201, response_model=RemoveImagesFromBoardResult, ) -async def remove_images_from_board( +def remove_images_from_board( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The names of the images to remove", embed=True), ) -> RemoveImagesFromBoardResult: diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index c6adeab850e..895f73b73c8 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -49,7 +49,7 @@ class DeleteBoardResult(BaseModel): status_code=201, response_model=BoardDTO, ) -async def create_board( +def create_board( current_user: CurrentUserOrDefault, board_name: str = Query(description="The name of the board to create", max_length=300), ) -> BoardDTO: @@ -62,7 +62,7 @@ async def create_board( @boards_router.get("/{board_id}", operation_id="get_board", response_model=BoardDTO) -async def get_board( +def get_board( current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of board to get"), ) -> BoardDTO: @@ -97,7 +97,7 @@ async def get_board( status_code=201, response_model=BoardDTO, ) -async def update_board( +def update_board( current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of board to update"), changes: BoardChanges = Body(description="The changes to apply to the board"), @@ -209,7 +209,7 @@ def delete_board( operation_id="list_boards", response_model=Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]], ) -async def list_boards( +def list_boards( current_user: CurrentUserOrDefault, order_by: BoardRecordOrderBy = Query(default=BoardRecordOrderBy.CreatedAt, description="The attribute to order by"), direction: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The direction to order by"), @@ -239,7 +239,7 @@ async def list_boards( operation_id="list_all_board_image_names", response_model=list[str], ) -async def list_all_board_image_names( +def list_all_board_image_names( current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of the board or 'none' for uncategorized images"), categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."), diff --git a/invokeai/app/api/routers/client_state.py b/invokeai/app/api/routers/client_state.py index cd92263f97c..07790c7182a 100644 --- a/invokeai/app/api/routers/client_state.py +++ b/invokeai/app/api/routers/client_state.py @@ -13,7 +13,7 @@ operation_id="get_client_state_by_key", response_model=str | None, ) -async def get_client_state_by_key( +def get_client_state_by_key( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to get"), @@ -31,7 +31,7 @@ async def get_client_state_by_key( operation_id="set_client_state", response_model=str, ) -async def set_client_state( +def set_client_state( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to set"), @@ -50,7 +50,7 @@ async def set_client_state( operation_id="get_client_state_keys_by_prefix", response_model=list[str], ) -async def get_client_state_keys_by_prefix( +def get_client_state_keys_by_prefix( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), prefix: str = Query(..., description="Prefix to filter keys by"), @@ -70,7 +70,7 @@ async def get_client_state_keys_by_prefix( operation_id="delete_client_state_by_key", responses={204: {"description": "Client state key deleted"}}, ) -async def delete_client_state_by_key( +def delete_client_state_by_key( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to delete"), @@ -88,7 +88,7 @@ async def delete_client_state_by_key( operation_id="delete_client_state", responses={204: {"description": "Client state deleted"}}, ) -async def delete_client_state( +def delete_client_state( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), ) -> None: diff --git a/invokeai/app/api/routers/custom_nodes.py b/invokeai/app/api/routers/custom_nodes.py index 35f6107d56a..0359e0b2ef6 100644 --- a/invokeai/app/api/routers/custom_nodes.py +++ b/invokeai/app/api/routers/custom_nodes.py @@ -154,7 +154,7 @@ def _get_installed_packs() -> list[NodePackInfo]: operation_id="list_custom_node_packs", response_model=NodePackListResponse, ) -async def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackListResponse: +def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackListResponse: """Lists all installed custom node packs. Admin-only: the response includes absolute filesystem paths, and non-admins have no @@ -169,7 +169,7 @@ async def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackL operation_id="install_custom_node_pack", response_model=InstallNodePackResponse, ) -async def install_custom_node_pack( +def install_custom_node_pack( current_admin: AdminUserOrDefault, request: InstallNodePackRequest = Body(description="The source URL to install from."), ) -> InstallNodePackResponse: @@ -285,7 +285,7 @@ async def install_custom_node_pack( operation_id="uninstall_custom_node_pack", response_model=UninstallNodePackResponse, ) -async def uninstall_custom_node_pack( +def uninstall_custom_node_pack( current_admin: AdminUserOrDefault, pack_name: str, ) -> UninstallNodePackResponse: @@ -357,7 +357,7 @@ async def uninstall_custom_node_pack( "/reload", operation_id="reload_custom_nodes", ) -async def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, str]: +def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, str]: """Triggers a reload of all custom nodes. This re-scans the nodes directory and loads any new node packs. diff --git a/invokeai/app/api/routers/download_queue.py b/invokeai/app/api/routers/download_queue.py index 305eaf9273e..8253f726701 100644 --- a/invokeai/app/api/routers/download_queue.py +++ b/invokeai/app/api/routers/download_queue.py @@ -45,7 +45,7 @@ def _validate_dest(dest: str) -> str: "/", operation_id="list_downloads", ) -async def list_downloads(current_user: CurrentUserOrDefault) -> List[DownloadJob]: +def list_downloads(current_user: CurrentUserOrDefault) -> List[DownloadJob]: """Get a list of active and inactive jobs.""" queue = ApiDependencies.invoker.services.download_queue return queue.list_jobs() @@ -59,7 +59,7 @@ async def list_downloads(current_user: CurrentUserOrDefault) -> List[DownloadJob 400: {"description": "Bad request"}, }, ) -async def prune_downloads(current_user: AdminUserOrDefault) -> Response: +def prune_downloads(current_user: AdminUserOrDefault) -> Response: """Prune completed and errored jobs.""" queue = ApiDependencies.invoker.services.download_queue queue.prune_jobs() @@ -70,7 +70,7 @@ async def prune_downloads(current_user: AdminUserOrDefault) -> Response: "/i/", operation_id="download", ) -async def download( +def download( current_user: CurrentUserOrDefault, source: AnyHttpUrl = Body(description="download source"), dest: str = Body(description="download destination"), @@ -91,7 +91,7 @@ async def download( 404: {"description": "The requested download JobID could not be found"}, }, ) -async def get_download_job( +def get_download_job( current_user: CurrentUserOrDefault, id: int = Path(description="ID of the download job to fetch."), ) -> DownloadJob: @@ -111,7 +111,7 @@ async def get_download_job( 404: {"description": "The requested download JobID could not be found"}, }, ) -async def cancel_download_job( +def cancel_download_job( current_user: CurrentUserOrDefault, id: int = Path(description="ID of the download job to cancel."), ) -> Response: @@ -132,7 +132,7 @@ async def cancel_download_job( 204: {"description": "Download jobs have been cancelled"}, }, ) -async def cancel_all_download_jobs(current_user: AdminUserOrDefault) -> Response: +def cancel_all_download_jobs(current_user: AdminUserOrDefault) -> Response: """Cancel all download jobs.""" ApiDependencies.invoker.services.download_queue.cancel_all_jobs() return Response(status_code=204) diff --git a/invokeai/app/api/routers/gallery.py b/invokeai/app/api/routers/gallery.py index a70822c5af1..1b63d31c43b 100644 --- a/invokeai/app/api/routers/gallery.py +++ b/invokeai/app/api/routers/gallery.py @@ -6,7 +6,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_common import GalleryItem, GalleryItemNamesResult +from invokeai.app.services.gallery.gallery_common import GalleryItem, GalleryItemNames, GalleryItemNamesResult from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection @@ -19,7 +19,7 @@ operation_id="list_gallery_items", response_model=OffsetPaginatedResults[GalleryItem], ) -async def list_gallery_items( +def list_gallery_items( current_user: CurrentUserOrDefault, origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."), categories: Optional[list[ImageCategory]] = Query( @@ -58,12 +58,63 @@ async def list_gallery_items( ) +@gallery_router.get( + "/item_names", + operation_id="list_gallery_item_names", + response_model=GalleryItemNames, +) +def list_gallery_item_names( + current_user: CurrentUserOrDefault, + origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."), + categories: Optional[list[ImageCategory]] = Query( + default=None, + description="The categories to include. Shared between images and videos.", + ), + is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate items."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find items without a board.", + ), + created_date: Optional[str] = Query( + default=None, + description="Restrict to items created on this ISO date, e.g. '2026-03-18'. Used by date-based virtual boards.", + ), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + starred_first: bool = Query(default=True, description="Whether to sort by starred items first"), + search_term: Optional[str] = Query(default=None, description="The term to search for"), +) -> GalleryItemNames: + """Returns the ordered flat list of item names — used to drive virtualized gallery selection. + + Names are polymorphic: image and video names are interleaved by `created_at`. A name ending + in `.mp4` is a video. + """ + if board_id is not None and board_id != "none": + _assert_board_read_access(board_id, current_user) + + try: + return ApiDependencies.invoker.services.gallery.get_item_names( + starred_first=starred_first, + order_dir=order_dir, + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=current_user.user_id, + is_admin=current_user.is_admin, + created_date=created_date, + ) + except Exception: + raise HTTPException(status_code=500, detail="Failed to get gallery item names") + + @gallery_router.get( "/items/names", operation_id="get_gallery_item_names", response_model=GalleryItemNamesResult, + deprecated=True, ) -async def get_gallery_item_names( +def get_gallery_item_names( current_user: CurrentUserOrDefault, origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."), categories: Optional[list[ImageCategory]] = Query( @@ -79,7 +130,12 @@ async def get_gallery_item_names( starred_first: bool = Query(default=True, description="Whether to sort by starred items first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> GalleryItemNamesResult: - """Returns an ordered (kind, name) list — used to drive virtualized gallery selection.""" + """Returns an ordered (kind, name) list — used to drive virtualized gallery selection. + + Deprecated: use `GET /v1/gallery/item_names`, which returns the same order as a flat name + list. The `kind` discriminator here costs a model per row — ~800ms on a 200k-item library — + for a value callers already derive from the file extension. + """ if board_id is not None and board_id != "none": _assert_board_read_access(board_id, current_user) diff --git a/invokeai/app/api/routers/image_moves.py b/invokeai/app/api/routers/image_moves.py index 0fe328ea9ea..ecbebc2074a 100644 --- a/invokeai/app/api/routers/image_moves.py +++ b/invokeai/app/api/routers/image_moves.py @@ -65,7 +65,7 @@ def _status_to_response(service_status: ImageMoveBackgroundStatus | dict) -> Ima response_model=ImageMoveStatusResponse, status_code=status.HTTP_202_ACCEPTED, ) -async def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: +def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: try: return _status_to_response(_get_image_move_service().start_background_move_all()) except (ImageMoveJobAlreadyRunning, ImageMoveQueueActive) as e: @@ -78,7 +78,7 @@ async def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: response_model=ImageMoveStatusResponse, status_code=status.HTTP_202_ACCEPTED, ) -async def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusResponse: +def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusResponse: try: return _status_to_response(_get_image_move_service().start_background_recovery()) except ImageMoveJobAlreadyRunning as e: @@ -90,5 +90,5 @@ async def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusRes operation_id="get_image_move_status", response_model=ImageMoveStatusResponse, ) -async def get_image_move_status(_: AdminUserOrDefault) -> ImageMoveStatusResponse: +def get_image_move_status(_: AdminUserOrDefault) -> ImageMoveStatusResponse: return _status_to_response(_get_image_move_service().get_background_status()) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index b9e06befb9c..eb0c7074294 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -187,7 +187,7 @@ class ImageUploadEntry(BaseModel): @images_router.post("/", operation_id="create_image_upload_entry") -async def create_image_upload_entry( +def create_image_upload_entry( _: CurrentUserOrDefault, width: int = Body(description="The width of the image"), height: int = Body(description="The height of the image"), @@ -199,7 +199,7 @@ async def create_image_upload_entry( @images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult) -async def delete_image( +def delete_image( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of the image to delete"), ) -> DeleteImagesResult: @@ -227,7 +227,7 @@ async def delete_image( @images_router.delete("/intermediates", operation_id="clear_intermediates") -async def clear_intermediates( +def clear_intermediates( current_user: CurrentUserOrDefault, ) -> int: """Clears all intermediates. Requires admin.""" @@ -243,7 +243,7 @@ async def clear_intermediates( @images_router.get("/intermediates", operation_id="get_intermediates_count") -async def get_intermediates_count( +def get_intermediates_count( current_user: CurrentUserOrDefault, ) -> int: """Gets the count of intermediate images. Non-admin users only see their own intermediates.""" @@ -260,7 +260,7 @@ async def get_intermediates_count( operation_id="update_image", response_model=ImageDTO, ) -async def update_image( +def update_image( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of the image to update"), image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"), @@ -280,7 +280,7 @@ async def update_image( operation_id="get_image_dto", response_model=ImageDTO, ) -async def get_image_dto( +def get_image_dto( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image to get"), ) -> ImageDTO: @@ -298,7 +298,7 @@ async def get_image_dto( operation_id="get_image_metadata", response_model=Optional[MetadataField], ) -async def get_image_metadata( +def get_image_metadata( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image to get"), ) -> Optional[MetadataField]: @@ -319,7 +319,7 @@ class WorkflowAndGraphResponse(BaseModel): @images_router.get( "/i/{image_name}/workflow", operation_id="get_image_workflow", response_model=WorkflowAndGraphResponse ) -async def get_image_workflow( +def get_image_workflow( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image whose workflow to get"), ) -> WorkflowAndGraphResponse: @@ -358,7 +358,7 @@ async def get_image_workflow( 404: {"description": "Image not found"}, }, ) -async def get_image_full( +def get_image_full( current_user: CurrentMediaUserOrDefault, image_name: str = Path(description="The name of full-resolution image file to get"), ) -> Response: @@ -394,7 +394,7 @@ async def get_image_full( 404: {"description": "Image not found"}, }, ) -async def get_image_thumbnail( +def get_image_thumbnail( current_user: CurrentMediaUserOrDefault, image_name: str = Path(description="The name of thumbnail image file to get"), ) -> Response: @@ -422,7 +422,7 @@ async def get_image_thumbnail( operation_id="get_image_urls", response_model=ImageUrlsDTO, ) -async def get_image_urls( +def get_image_urls( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of the image whose URL to get"), ) -> ImageUrlsDTO: @@ -446,7 +446,7 @@ async def get_image_urls( operation_id="list_image_dtos", response_model=OffsetPaginatedResults[ImageDTO], ) -async def list_image_dtos( +def list_image_dtos( current_user: CurrentUserOrDefault, image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), @@ -486,7 +486,7 @@ async def list_image_dtos( @images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult) -async def delete_images_from_list( +def delete_images_from_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to delete", embed=True), ) -> DeleteImagesResult: @@ -534,7 +534,7 @@ async def delete_images_from_list( @images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult) -async def delete_uncategorized_images( +def delete_uncategorized_images( current_user: CurrentUserOrDefault, ) -> DeleteImagesResult: """Deletes all uncategorized images owned by the current user (or all if admin)""" @@ -573,7 +573,7 @@ class ImagesUpdatedFromListResult(BaseModel): @images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult) -async def star_images_in_list( +def star_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to star", embed=True), ) -> StarredImagesResult: @@ -610,7 +610,7 @@ async def star_images_in_list( @images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult) -async def unstar_images_in_list( +def unstar_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to unstar", embed=True), ) -> UnstarredImagesResult: @@ -658,7 +658,7 @@ class ImagesDownloaded(BaseModel): @images_router.post( "/download", operation_id="download_images_from_list", response_model=ImagesDownloaded, status_code=202 ) -async def download_images_from_list( +def download_images_from_list( current_user: CurrentUserOrDefault, background_tasks: BackgroundTasks, image_names: Optional[list[str]] = Body( @@ -707,7 +707,7 @@ async def download_images_from_list( 404: {"description": "Image not found"}, }, ) -async def get_bulk_download_item( +def get_bulk_download_item( current_user: CurrentUserOrDefault, background_tasks: BackgroundTasks, bulk_download_item_name: str = Path(description="The bulk_download_item_name of the bulk download item to get"), @@ -740,8 +740,8 @@ async def get_bulk_download_item( raise HTTPException(status_code=404) -@images_router.get("/names", operation_id="get_image_names") -async def get_image_names( +@images_router.get("/names", operation_id="get_image_names", deprecated=True) +def get_image_names( current_user: CurrentUserOrDefault, image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), @@ -754,7 +754,11 @@ async def get_image_names( starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> ImageNamesResult: - """Gets ordered list of image names with metadata for optimistic updates""" + """Gets ordered list of image names with metadata for optimistic updates. + + Deprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved + in one ordered list. This image-only endpoint predates the polymorphic gallery. + """ # Validate that the caller can read from this board before listing its images. if board_id is not None and board_id != "none": @@ -782,7 +786,7 @@ async def get_image_names( operation_id="get_images_by_names", responses={200: {"model": list[ImageDTO]}}, ) -async def get_images_by_names( +def get_images_by_names( current_user: CurrentUserOrDefault, image_names: list[str] = Body(embed=True, description="Object containing list of image names to fetch DTOs for"), ) -> list[ImageDTO]: diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 09c9a8bca06..f3d9d045963 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -156,7 +156,7 @@ def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type "/", operation_id="list_model_records", ) -async def list_model_records( +def list_model_records( current_user: CurrentUserOrDefault, base_models: Optional[List[BaseModelType]] = Query(default=None, description="Base models to include"), model_type: Optional[ModelType] = Query(default=None, description="The type of model to get"), @@ -202,7 +202,7 @@ async def list_model_records( operation_id="list_missing_models", responses={200: {"description": "List of models with missing files"}}, ) -async def list_missing_models(current_user: CurrentUserOrDefault) -> ModelsList: +def list_missing_models(current_user: CurrentUserOrDefault) -> ModelsList: """Get models whose files are missing from disk. These are models that have database entries but their corresponding @@ -229,7 +229,7 @@ async def list_missing_models(current_user: CurrentUserOrDefault) -> ModelsList: operation_id="get_model_records_by_attrs", response_model=AnyModelConfig, ) -async def get_model_records_by_attrs( +def get_model_records_by_attrs( current_user: CurrentUserOrDefault, name: str = Query(description="The name of the model"), type: ModelType = Query(description="The type of the model"), @@ -251,7 +251,7 @@ async def get_model_records_by_attrs( operation_id="get_model_records_by_hash", response_model=AnyModelConfig, ) -async def get_model_records_by_hash( +def get_model_records_by_hash( current_user: CurrentUserOrDefault, hash: str = Query(description="The hash of the model"), ) -> AnyModelConfig: @@ -276,7 +276,7 @@ async def get_model_records_by_hash( 404: {"description": "The model could not be found"}, }, ) -async def get_model_record( +def get_model_record( current_user: CurrentUserOrDefault, key: str = Path(description="Key of the model record to fetch."), ) -> AnyModelConfig: @@ -300,7 +300,7 @@ async def get_model_record( 404: {"description": "The model could not be found"}, }, ) -async def reidentify_model( +def reidentify_model( key: Annotated[str, Path(description="Key of the model to reidentify.")], current_admin: AdminUserOrDefault, ) -> AnyModelConfig: @@ -349,7 +349,7 @@ class FoundModel(BaseModel): status_code=200, response_model=List[FoundModel], ) -async def scan_for_models( +def scan_for_models( current_admin: AdminUserOrDefault, scan_path: str = Query(description="Directory path to search for models", default=None), ) -> List[FoundModel]: @@ -415,7 +415,7 @@ class HuggingFaceModels(BaseModel): status_code=200, response_model=HuggingFaceModels, ) -async def get_hugging_face_models( +def get_hugging_face_models( current_admin: AdminUserOrDefault, hugging_face_repo: str = Query(description="Hugging face repo to search for models", default=None), ) -> HuggingFaceModels: @@ -523,7 +523,7 @@ def _load_settings_changed(previous: AnyModelConfig, updated: AnyModelConfig) -> }, status_code=200, ) -async def get_model_image( +def get_model_image( key: str = Path(description="The name of model image file to get"), ) -> FileResponse: """Gets an image file that previews the model""" @@ -590,7 +590,7 @@ async def update_model_image( }, status_code=204, ) -async def delete_model( +def delete_model( current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of model to remove from model registry."), ) -> Response: @@ -646,7 +646,7 @@ class BulkReidentifyModelsResponse(BaseModel): }, status_code=200, ) -async def bulk_delete_models( +def bulk_delete_models( current_admin: AdminUserOrDefault, request: BulkDeleteModelsRequest = Body(description="List of model keys to delete"), ) -> BulkDeleteModelsResponse: @@ -687,7 +687,7 @@ async def bulk_delete_models( }, status_code=200, ) -async def bulk_reidentify_models( +def bulk_reidentify_models( current_admin: AdminUserOrDefault, request: BulkReidentifyModelsRequest = Body(description="List of model keys to reidentify"), ) -> BulkReidentifyModelsResponse: @@ -749,7 +749,7 @@ async def bulk_reidentify_models( }, status_code=204, ) -async def delete_model_image( +def delete_model_image( current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of model image to remove from model_images directory."), ) -> None: @@ -775,7 +775,7 @@ async def delete_model_image( }, status_code=201, ) -async def install_model( +def install_model( current_admin: AdminUserOrDefault, source: str = Query(description="Model source to install, can be a local path, repo_id, or remote URL"), inplace: Optional[bool] = Query(description="Whether or not to install a local model in place", default=False), @@ -846,7 +846,7 @@ async def install_model( status_code=201, response_class=HTMLResponse, ) -async def install_hugging_face_model( +def install_hugging_face_model( current_admin: AdminUserOrDefault, source: str = Query(description="HuggingFace repo_id to install"), ) -> HTMLResponse: @@ -967,7 +967,7 @@ def generate_html(title: str, heading: str, repo_id: str, is_error: bool, messag "/install", operation_id="list_model_installs", ) -async def list_model_installs(current_admin: AdminUserOrDefault) -> List[ModelInstallJob]: +def list_model_installs(current_admin: AdminUserOrDefault) -> List[ModelInstallJob]: """Return the list of model install jobs. Install jobs have a numeric `id`, a `status`, and other fields that provide information on @@ -999,7 +999,7 @@ async def list_model_installs(current_admin: AdminUserOrDefault) -> List[ModelIn 404: {"description": "No such job"}, }, ) -async def get_model_install_job( +def get_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install id") ) -> ModelInstallJob: """ @@ -1022,7 +1022,7 @@ async def get_model_install_job( }, status_code=201, ) -async def cancel_model_install_job( +def cancel_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID"), ) -> None: @@ -1044,7 +1044,7 @@ async def cancel_model_install_job( }, status_code=201, ) -async def pause_model_install_job( +def pause_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID") ) -> ModelInstallJob: """Pause the model install job corresponding to the given job ID.""" @@ -1066,7 +1066,7 @@ async def pause_model_install_job( }, status_code=201, ) -async def resume_model_install_job( +def resume_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID") ) -> ModelInstallJob: """Resume a paused model install job corresponding to the given job ID.""" @@ -1088,7 +1088,7 @@ async def resume_model_install_job( }, status_code=201, ) -async def restart_failed_model_install_job( +def restart_failed_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID") ) -> ModelInstallJob: """Restart failed or non-resumable file downloads for the given job.""" @@ -1110,7 +1110,7 @@ async def restart_failed_model_install_job( }, status_code=201, ) -async def restart_model_install_file( +def restart_model_install_file( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID"), file_source: AnyHttpUrl = Body(description="File download URL to restart"), @@ -1133,7 +1133,7 @@ async def restart_model_install_file( 400: {"description": "Bad request"}, }, ) -async def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Response: +def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Response: """Prune all completed and errored jobs from the install job list.""" ApiDependencies.invoker.services.model_manager.install.prune_jobs() return Response(status_code=204) @@ -1152,7 +1152,7 @@ async def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Respons 409: {"description": "There is already a model registered at this location"}, }, ) -async def convert_model( +def convert_model( current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of the safetensors main model to convert to diffusers format."), ) -> AnyModelConfig: @@ -1291,7 +1291,7 @@ def get_is_installed( @model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=StarterModelResponse) -async def get_starter_models(current_admin: AdminUserOrDefault) -> StarterModelResponse: +def get_starter_models(current_admin: AdminUserOrDefault) -> StarterModelResponse: installed_models = ApiDependencies.invoker.services.model_manager.store.search_by_attr() starter_models = deepcopy(STARTER_MODELS) starter_bundles = deepcopy(STARTER_BUNDLES) @@ -1324,7 +1324,7 @@ async def get_starter_models(current_admin: AdminUserOrDefault) -> StarterModelR response_model=Optional[CacheStats], summary="Get model manager RAM cache performance statistics.", ) -async def get_stats(current_admin: AdminUserOrDefault) -> Optional[CacheStats]: +def get_stats(current_admin: AdminUserOrDefault) -> Optional[CacheStats]: """Return performance statistics on the model manager's RAM cache. In multi-GPU mode there is one cache per generation device; their statistics are aggregated. Will return null if no models have been loaded.""" @@ -1364,7 +1364,7 @@ async def get_stats(current_admin: AdminUserOrDefault) -> Optional[CacheStats]: operation_id="empty_model_cache", status_code=200, ) -async def empty_model_cache(current_admin: AdminUserOrDefault) -> None: +def empty_model_cache(current_admin: AdminUserOrDefault) -> None: """Drop all models from the model cache to free RAM/VRAM. 'Locked' models that are in active use will not be dropped.""" # Request 1000GB of room in order to force each per-device cache to drop all models. ApiDependencies.invoker.services.logger.info("Emptying model cache.") @@ -1404,7 +1404,7 @@ def reset_token(cls) -> HFTokenStatus: @model_manager_router.get("/hf_login", operation_id="get_hf_login_status", response_model=HFTokenStatus) -async def get_hf_login_status(current_admin: AdminUserOrDefault) -> HFTokenStatus: +def get_hf_login_status(current_admin: AdminUserOrDefault) -> HFTokenStatus: token_status = HFTokenHelper.get_status() if token_status is HFTokenStatus.UNKNOWN: @@ -1414,7 +1414,7 @@ async def get_hf_login_status(current_admin: AdminUserOrDefault) -> HFTokenStatu @model_manager_router.post("/hf_login", operation_id="do_hf_login", response_model=HFTokenStatus) -async def do_hf_login( +def do_hf_login( current_admin: AdminUserOrDefault, token: str = Body(description="Hugging Face token to use for login", embed=True), ) -> HFTokenStatus: @@ -1428,7 +1428,7 @@ async def do_hf_login( @model_manager_router.delete("/hf_login", operation_id="reset_hf_token", response_model=HFTokenStatus) -async def reset_hf_token(current_admin: AdminUserOrDefault) -> HFTokenStatus: +def reset_hf_token(current_admin: AdminUserOrDefault) -> HFTokenStatus: return HFTokenHelper.reset_token() @@ -1453,7 +1453,7 @@ class DeleteOrphanedModelsResponse(BaseModel): operation_id="get_orphaned_models", response_model=list[OrphanedModelInfo], ) -async def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]: +def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]: """Find orphaned model directories. Orphaned models are directories in the models folder that contain model files @@ -1480,9 +1480,7 @@ async def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]: operation_id="delete_orphaned_models", response_model=DeleteOrphanedModelsResponse, ) -async def delete_orphaned_models( - request: DeleteOrphanedModelsRequest, _: AdminUserOrDefault -) -> DeleteOrphanedModelsResponse: +def delete_orphaned_models(request: DeleteOrphanedModelsRequest, _: AdminUserOrDefault) -> DeleteOrphanedModelsResponse: """Delete specified orphaned model directories. Args: diff --git a/invokeai/app/api/routers/model_relationships.py b/invokeai/app/api/routers/model_relationships.py index 0ec45070955..3a882038c0f 100644 --- a/invokeai/app/api/routers/model_relationships.py +++ b/invokeai/app/api/routers/model_relationships.py @@ -85,7 +85,7 @@ class ModelRelationshipBatchRequest(BaseModel): 422: {"description": "Validation error"}, }, ) -async def get_related_models( +def get_related_models( current_user: CurrentUserOrDefault, model_key: str = Path(..., description="The key of the model to get relationships for"), ) -> list[str]: @@ -108,7 +108,7 @@ async def get_related_models( summary="Add Model Relationship", description="Creates a **bidirectional** relationship between two models, allowing each to reference the other as related.", ) -async def add_model_relationship( +def add_model_relationship( current_user: AdminUserOrDefault, req: ModelRelationshipCreateRequest = Body(..., description="The model keys to relate"), ) -> None: @@ -145,7 +145,7 @@ async def add_model_relationship( summary="Remove Model Relationship", description="Removes a **bidirectional** relationship between two models. The relationship must already exist.", ) -async def remove_model_relationship( +def remove_model_relationship( current_user: AdminUserOrDefault, req: ModelRelationshipCreateRequest = Body(..., description="The model keys to disconnect"), ) -> None: @@ -194,7 +194,7 @@ async def remove_model_relationship( summary="Get Related Model Keys (Batch)", description="Retrieves all **unique related model keys** for a list of given models. This is useful for contextual suggestions or filtering.", ) -async def get_related_models_batch( +def get_related_models_batch( current_user: CurrentUserOrDefault, req: ModelRelationshipBatchRequest = Body(..., description="Model keys to check for related connections"), ) -> list[str]: diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py index 1f96280f4f3..44ba5e05a4b 100644 --- a/invokeai/app/api/routers/recall_parameters.py +++ b/invokeai/app/api/routers/recall_parameters.py @@ -399,7 +399,7 @@ def _assert_recall_image_access(parameters: "RecallParameter", current_user: Cur operation_id="update_recall_parameters", response_model=dict[str, Any], ) -async def update_recall_parameters( +def update_recall_parameters( current_user: CurrentUserOrDefault, queue_id: str = Path(..., description="The queue id to perform this operation on"), parameters: RecallParameter = Body(..., description="Recall parameters to update"), @@ -585,7 +585,7 @@ async def update_recall_parameters( operation_id="get_recall_parameters", response_model=dict[str, Any], ) -async def get_recall_parameters( +def get_recall_parameters( current_user: CurrentUserOrDefault, queue_id: str = Path(..., description="The queue id to retrieve parameters for"), ) -> dict[str, Any]: diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index f2ec8f7cb63..f9bbbf88bce 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -24,6 +24,7 @@ SessionQueueCountsByDestination, SessionQueueItem, SessionQueueItemNotFoundError, + SessionQueueItemSummary, SessionQueueStatus, ) from invokeai.app.services.shared.graph import Graph, GraphExecutionState @@ -95,6 +96,27 @@ def sanitize_queue_item_for_user( return sanitized_item +def sanitize_queue_item_summary_for_user( + queue_item: SessionQueueItemSummary, current_user_id: str, is_admin: bool +) -> SessionQueueItemSummary: + """Remove queue-list metadata belonging to another user for non-admin callers.""" + if is_admin or queue_item.user_id == current_user_id: + return queue_item + + return queue_item.model_copy( + update={ + "device": None, + "origin": None, + "destination": None, + "batch_id": "redacted", + "user_id": "redacted", + "user_display_name": None, + "user_email": None, + "field_values": None, + } + ) + + @session_queue_router.post( "/{queue_id}/enqueue_batch", operation_id="enqueue_batch", @@ -126,7 +148,7 @@ async def enqueue_batch( 200: {"model": list[SessionQueueItem]}, }, ) -async def list_all_queue_items( +def list_all_queue_items( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), @@ -150,7 +172,7 @@ async def list_all_queue_items( 200: {"model": ItemIdsResult}, }, ) -async def get_queue_item_ids( +def get_queue_item_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), @@ -176,7 +198,7 @@ async def get_queue_item_ids( operation_id="get_queue_items_by_item_ids", responses={200: {"model": list[SessionQueueItem]}}, ) -async def get_queue_items_by_item_ids( +def get_queue_items_by_item_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_ids: list[int] = Body( @@ -206,12 +228,37 @@ async def get_queue_items_by_item_ids( raise HTTPException(status_code=500, detail="Failed to get queue items") +@session_queue_router.post( + "/{queue_id}/item_summaries_by_ids", + operation_id="get_queue_item_summaries_by_ids", + responses={200: {"model": list[SessionQueueItemSummary]}}, +) +def get_queue_item_summaries_by_ids( + current_user: CurrentUserOrDefault, + queue_id: str = Path(description="The queue id to perform this operation on"), + item_ids: list[int] = Body( + embed=True, description="Object containing list of queue item ids to fetch summaries for" + ), +) -> list[SessionQueueItemSummary]: + """Gets lightweight queue item summaries for specified IDs in requested order.""" + try: + summaries = ApiDependencies.invoker.services.session_queue.get_queue_item_summaries_by_ids( + queue_id=queue_id, item_ids=item_ids + ) + return [ + sanitize_queue_item_summary_for_user(item, current_user.user_id, current_user.is_admin) + for item in summaries + ] + except Exception: + raise HTTPException(status_code=500, detail="Failed to get queue item summaries") + + @session_queue_router.put( "/{queue_id}/processor/resume", operation_id="resume", responses={200: {"model": SessionProcessorStatus}}, ) -async def resume( +def resume( current_user: AdminUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: @@ -227,7 +274,7 @@ async def resume( operation_id="pause", responses={200: {"model": SessionProcessorStatus}}, ) -async def pause( +def pause( current_user: AdminUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: @@ -243,7 +290,7 @@ async def pause( operation_id="cancel_all_except_current", responses={200: {"model": CancelAllExceptCurrentResult}}, ) -async def cancel_all_except_current( +def cancel_all_except_current( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> CancelAllExceptCurrentResult: @@ -263,7 +310,7 @@ async def cancel_all_except_current( operation_id="delete_all_except_current", responses={200: {"model": DeleteAllExceptCurrentResult}}, ) -async def delete_all_except_current( +def delete_all_except_current( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> DeleteAllExceptCurrentResult: @@ -283,7 +330,7 @@ async def delete_all_except_current( operation_id="cancel_by_batch_ids", responses={200: {"model": CancelByBatchIDsResult}}, ) -async def cancel_by_batch_ids( +def cancel_by_batch_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), batch_ids: list[str] = Body(description="The list of batch_ids to cancel all queue items for", embed=True), @@ -304,7 +351,7 @@ async def cancel_by_batch_ids( operation_id="cancel_by_destination", responses={200: {"model": CancelByDestinationResult}}, ) -async def cancel_by_destination( +def cancel_by_destination( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), destination: str = Query(description="The destination to cancel all queue items for"), @@ -325,7 +372,7 @@ async def cancel_by_destination( operation_id="retry_items_by_id", responses={200: {"model": RetryItemsResult}}, ) -async def retry_items_by_id( +def retry_items_by_id( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_ids: list[int] = Body(description="The queue item ids to retry"), @@ -371,7 +418,7 @@ async def retry_items_by_id( 200: {"model": ClearResult}, }, ) -async def clear( +def clear( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> ClearResult: @@ -398,7 +445,7 @@ async def clear( 200: {"model": PruneResult}, }, ) -async def prune( +def prune( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> PruneResult: @@ -418,7 +465,7 @@ async def prune( 200: {"model": Optional[SessionQueueItem]}, }, ) -async def get_current_queue_item( +def get_current_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> Optional[SessionQueueItem]: @@ -439,7 +486,7 @@ async def get_current_queue_item( 200: {"model": Optional[SessionQueueItem]}, }, ) -async def get_next_queue_item( +def get_next_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> Optional[SessionQueueItem]: @@ -460,7 +507,7 @@ async def get_next_queue_item( 200: {"model": SessionQueueAndProcessorStatus}, }, ) -async def get_queue_status( +def get_queue_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionQueueAndProcessorStatus: @@ -485,7 +532,7 @@ async def get_queue_status( 200: {"model": BatchStatus}, }, ) -async def get_batch_status( +def get_batch_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), batch_id: str = Path(description="The batch to get the status of"), @@ -508,7 +555,7 @@ async def get_batch_status( }, response_model_exclude_none=True, ) -async def get_queue_item( +def get_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to get"), @@ -530,7 +577,7 @@ async def get_queue_item( "/{queue_id}/i/{item_id}", operation_id="delete_queue_item", ) -async def delete_queue_item( +def delete_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to delete"), @@ -566,7 +613,7 @@ async def delete_queue_item( 200: {"model": SessionQueueItem}, }, ) -async def cancel_queue_item( +def cancel_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to cancel"), @@ -596,7 +643,7 @@ async def cancel_queue_item( operation_id="counts_by_destination", responses={200: {"model": SessionQueueCountsByDestination}}, ) -async def counts_by_destination( +def counts_by_destination( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to query"), destination: str = Query(description="The destination to query"), @@ -616,7 +663,7 @@ async def counts_by_destination( operation_id="delete_by_destination", responses={200: {"model": DeleteByDestinationResult}}, ) -async def delete_by_destination( +def delete_by_destination( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to query"), destination: str = Path(description="The destination to query"), diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index 91acf8e7a6b..d470da1e8cf 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -78,7 +78,7 @@ def _load_record_or_404(style_preset_id: str) -> StylePresetRecordDTO: 200: {"model": StylePresetRecordWithImage}, }, ) -async def get_style_preset( +def get_style_preset( current_user: CurrentUserOrDefault, style_preset_id: str = Path(description="The style preset to get"), ) -> StylePresetRecordWithImage: @@ -157,7 +157,7 @@ async def update_style_preset( "/i/{style_preset_id}", operation_id="delete_style_preset", ) -async def delete_style_preset( +def delete_style_preset( current_user: CurrentUserOrDefault, style_preset_id: str = Path(description="The style preset to delete"), ) -> None: @@ -238,7 +238,7 @@ async def create_style_preset( 200: {"model": list[StylePresetRecordWithImage]}, }, ) -async def list_style_presets(current_user: CurrentUserOrDefault) -> list[StylePresetRecordWithImage]: +def list_style_presets(current_user: CurrentUserOrDefault) -> list[StylePresetRecordWithImage]: """Gets the style presets visible to the current user.""" style_presets_with_image: list[StylePresetRecordWithImage] = [] style_presets = ApiDependencies.invoker.services.style_preset_records.get_many( @@ -265,7 +265,7 @@ async def list_style_presets(current_user: CurrentUserOrDefault) -> list[StylePr }, status_code=200, ) -async def get_style_preset_image( +def get_style_preset_image( current_user: CurrentUserOrDefault, style_preset_id: str = Path(description="The id of the style preset image to get"), ) -> FileResponse: @@ -294,7 +294,7 @@ async def get_style_preset_image( responses={200: {"content": {"text/csv": {}}, "description": "A CSV file with the requested data."}}, status_code=200, ) -async def export_style_presets(current_user: AdminUserOrDefault): +def export_style_presets(current_user: AdminUserOrDefault): # Admin-only export covers every user preset. output = io.StringIO() writer = csv.writer(output) diff --git a/invokeai/app/api/routers/system_prompts.py b/invokeai/app/api/routers/system_prompts.py index f0fa9ac7b50..6bfbba7ab58 100644 --- a/invokeai/app/api/routers/system_prompts.py +++ b/invokeai/app/api/routers/system_prompts.py @@ -19,7 +19,7 @@ operation_id="list_system_prompts", responses={200: {"model": list[SystemPromptRecordDTO]}}, ) -async def list_system_prompts(current_user: CurrentUserOrDefault) -> list[SystemPromptRecordDTO]: +def list_system_prompts(current_user: CurrentUserOrDefault) -> list[SystemPromptRecordDTO]: """Lists system prompts visible to the current user (own + public).""" config = ApiDependencies.invoker.services.configuration # Admins (and single-user installs) see everything; multiuser non-admins are scoped to own + public. @@ -34,7 +34,7 @@ async def list_system_prompts(current_user: CurrentUserOrDefault) -> list[System operation_id="get_system_prompt", responses={200: {"model": SystemPromptRecordDTO}}, ) -async def get_system_prompt( +def get_system_prompt( current_user: CurrentUserOrDefault, system_prompt_id: str = Path(description="The id of the system prompt to get"), ) -> SystemPromptRecordDTO: @@ -57,7 +57,7 @@ async def get_system_prompt( operation_id="create_system_prompt", responses={200: {"model": SystemPromptRecordDTO}}, ) -async def create_system_prompt( +def create_system_prompt( current_user: CurrentUserOrDefault, system_prompt: SystemPromptWithoutId = Body(description="The system prompt to create"), ) -> SystemPromptRecordDTO: @@ -75,7 +75,7 @@ async def create_system_prompt( operation_id="update_system_prompt", responses={200: {"model": SystemPromptRecordDTO}}, ) -async def update_system_prompt( +def update_system_prompt( current_user: CurrentUserOrDefault, system_prompt_id: str = Path(description="The id of the system prompt to update"), changes: SystemPromptChanges = Body(description="The changes to apply"), @@ -100,7 +100,7 @@ async def update_system_prompt( "/i/{system_prompt_id}", operation_id="delete_system_prompt", ) -async def delete_system_prompt( +def delete_system_prompt( current_user: CurrentUserOrDefault, system_prompt_id: str = Path(description="The id of the system prompt to delete"), ) -> None: diff --git a/invokeai/app/api/routers/utilities.py b/invokeai/app/api/routers/utilities.py index 023f653df6c..48e4d885366 100644 --- a/invokeai/app/api/routers/utilities.py +++ b/invokeai/app/api/routers/utilities.py @@ -45,7 +45,7 @@ class DynamicPromptsResponse(BaseModel): 200: {"model": DynamicPromptsResponse}, }, ) -async def parse_dynamicprompts( +def parse_dynamicprompts( current_user: CurrentUserOrDefault, prompt: str = Body(description="The prompt to parse with dynamicprompts"), max_prompts: int = Body(ge=1, le=10000, default=1000, description="The max number of prompts to generate"), diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index c535aa3b931..e1885dd2c44 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -347,7 +347,7 @@ async def upload_video( @videos_router.delete("/i/{video_name}", operation_id="delete_video", response_model=DeleteVideosResult) -async def delete_video( +def delete_video( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video to delete"), ) -> DeleteVideosResult: @@ -452,7 +452,7 @@ def delete_uncategorized_videos( @videos_router.patch("/i/{video_name}", operation_id="update_video", response_model=VideoDTO) -async def update_video( +def update_video( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video to update"), video_changes: VideoRecordChanges = Body(description="The changes to apply to the video"), @@ -465,7 +465,7 @@ async def update_video( @videos_router.get("/i/{video_name}", operation_id="get_video_dto", response_model=VideoDTO) -async def get_video_dto( +def get_video_dto( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of video to get"), ) -> VideoDTO: @@ -479,7 +479,7 @@ async def get_video_dto( @videos_router.get( "/i/{video_name}/metadata", operation_id="get_video_metadata", response_model=Optional[MetadataField] ) -async def get_video_metadata( +def get_video_metadata( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of video to get"), ) -> Optional[MetadataField]: @@ -493,7 +493,7 @@ async def get_video_metadata( @videos_router.get( "/i/{video_name}/workflow", operation_id="get_video_workflow", response_model=WorkflowAndGraphResponse ) -async def get_video_workflow( +def get_video_workflow( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of video whose workflow to get"), ) -> WorkflowAndGraphResponse: @@ -567,7 +567,7 @@ def _parse_range_header(range_header: str, file_size: int) -> Optional[tuple[int 404: {"description": "Video not found"}, }, ) -async def get_video_full( +def get_video_full( request: Request, current_user: CurrentMediaUserOrDefault, video_name: str = PathParam(description="The name of video file to get"), @@ -670,7 +670,7 @@ def iter_video() -> Iterator[bytes]: 404: {"description": "Video not found"}, }, ) -async def get_video_thumbnail( +def get_video_thumbnail( current_user: CurrentMediaUserOrDefault, video_name: str = PathParam(description="The name of thumbnail file to get"), ) -> Response: @@ -694,7 +694,7 @@ async def get_video_thumbnail( @videos_router.get("/i/{video_name}/urls", operation_id="get_video_urls", response_model=VideoUrlsDTO) -async def get_video_urls( +def get_video_urls( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video whose URL to get"), ) -> VideoUrlsDTO: @@ -708,7 +708,7 @@ async def get_video_urls( @videos_router.get("/", operation_id="list_video_dtos", response_model=OffsetPaginatedResults[VideoDTO]) -async def list_video_dtos( +def list_video_dtos( current_user: CurrentUserOrDefault, video_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of videos to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of video to include."), @@ -745,8 +745,8 @@ async def list_video_dtos( ) -@videos_router.get("/names", operation_id="get_video_names") -async def get_video_names( +@videos_router.get("/names", operation_id="get_video_names", deprecated=True) +def get_video_names( current_user: CurrentUserOrDefault, video_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of videos to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of video to include."), @@ -759,7 +759,11 @@ async def get_video_names( starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> VideoNamesResult: - """Gets ordered list of video names with metadata for optimistic updates.""" + """Gets ordered list of video names with metadata for optimistic updates. + + Deprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved + in one ordered list. This video-only endpoint predates the polymorphic gallery. + """ # Validate that the caller can read from this board. "none" is handled by the SQL layer. if board_id is not None and board_id != "none": _assert_board_read_access(board_id, current_user) @@ -849,7 +853,7 @@ class VideoBoardArg(BaseModel): operation_id="add_video_to_board", response_model=AddVideosToBoardResult, ) -async def add_video_to_board( +def add_video_to_board( current_user: CurrentUserOrDefault, arg: VideoBoardArg = Body(), ) -> AddVideosToBoardResult: @@ -877,7 +881,7 @@ async def add_video_to_board( operation_id="remove_video_from_board", response_model=RemoveVideosFromBoardResult, ) -async def remove_video_from_board( +def remove_video_from_board( current_user: CurrentUserOrDefault, video_name: str = Body(description="The name of the video to remove from its board", embed=True), ) -> RemoveVideosFromBoardResult: diff --git a/invokeai/app/api/routers/virtual_boards.py b/invokeai/app/api/routers/virtual_boards.py index 78902dd5dec..9837cbd899e 100644 --- a/invokeai/app/api/routers/virtual_boards.py +++ b/invokeai/app/api/routers/virtual_boards.py @@ -16,7 +16,7 @@ operation_id="list_virtual_boards_by_date", response_model=list[VirtualSubBoardDTO], ) -async def list_virtual_boards_by_date( +def list_virtual_boards_by_date( current_user: CurrentUserOrDefault, ) -> list[VirtualSubBoardDTO]: """Gets a list of virtual sub-boards grouped by date. Covers both images and videos.""" @@ -33,8 +33,9 @@ async def list_virtual_boards_by_date( "/by_date/{date}/image_names", operation_id="list_virtual_board_image_names_by_date", response_model=ImageNamesResult, + deprecated=True, ) -async def list_virtual_board_image_names_by_date( +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"), @@ -42,8 +43,11 @@ async def list_virtual_board_image_names_by_date( 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. Image-only; kept for API compatibility — - the UI uses the polymorphic `/by_date/{date}/item_names` endpoint.""" + """Gets ordered image names for a specific date. Image-only. + + Deprecated: use `GET /v1/gallery/item_names?created_date=`, which covers images and + videos in one ordered list. + """ try: return ApiDependencies.invoker.services.image_records.get_image_names_by_date( date=date, @@ -62,8 +66,9 @@ async def list_virtual_board_image_names_by_date( "/by_date/{date}/item_names", operation_id="list_virtual_board_item_names_by_date", response_model=GalleryItemNamesResult, + deprecated=True, ) -async def list_virtual_board_item_names_by_date( +def list_virtual_board_item_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 items first"), @@ -71,7 +76,11 @@ async def list_virtual_board_item_names_by_date( 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"), ) -> GalleryItemNamesResult: - """Gets ordered polymorphic (image + video) item refs for a specific date.""" + """Gets ordered polymorphic (image + video) item refs for a specific date. + + Deprecated: use `GET /v1/gallery/item_names?created_date=`, which returns the same + order as a flat name list instead of one model per item. + """ try: return ApiDependencies.invoker.services.gallery.list_item_names( starred_first=starred_first, diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 768a8f4e8d5..14707ebf1e1 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -35,7 +35,7 @@ 200: {"model": WorkflowRecordWithThumbnailDTO}, }, ) -async def get_workflow( +def get_workflow( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to get"), ) -> WorkflowRecordWithThumbnailDTO: @@ -74,7 +74,7 @@ async def get_workflow( 200: {"model": WorkflowRecordDTO}, }, ) -async def update_workflow( +def update_workflow( current_user: CurrentUserOrDefault, workflow: Workflow = Body(description="The updated workflow", embed=True), ) -> WorkflowRecordDTO: @@ -103,7 +103,7 @@ async def update_workflow( "/i/{workflow_id}", operation_id="delete_workflow", ) -async def delete_workflow( +def delete_workflow( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to delete"), ) -> None: @@ -138,7 +138,7 @@ async def delete_workflow( 200: {"model": WorkflowRecordDTO}, }, ) -async def create_workflow( +def create_workflow( current_user: CurrentUserOrDefault, workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True), ) -> WorkflowRecordDTO: @@ -165,7 +165,7 @@ async def create_workflow( 200: {"model": PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]}, }, ) -async def list_workflows( +def list_workflows( current_user: CurrentUserOrDefault, page: int = Query(default=0, description="The page to get"), per_page: Optional[int] = Query(default=None, description="The number of workflows per page"), @@ -306,7 +306,7 @@ async def set_workflow_thumbnail( 200: {"model": WorkflowRecordDTO}, }, ) -async def delete_workflow_thumbnail( +def delete_workflow_thumbnail( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to update"), ): @@ -338,7 +338,7 @@ async def delete_workflow_thumbnail( }, status_code=200, ) -async def get_workflow_thumbnail( +def get_workflow_thumbnail( workflow_id: str = Path(description="The id of the workflow thumbnail to get"), ) -> FileResponse: """Gets a workflow's thumbnail image. @@ -369,7 +369,7 @@ async def get_workflow_thumbnail( 200: {"model": WorkflowRecordDTO}, }, ) -async def update_workflow_is_public( +def update_workflow_is_public( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to update"), is_public: bool = Body(description="Whether the workflow should be shared publicly", embed=True), @@ -398,7 +398,7 @@ async def update_workflow_is_public( @workflows_router.get("/tags", operation_id="get_all_tags") -async def get_all_tags( +def get_all_tags( current_user: CurrentUserOrDefault, categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"), is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"), @@ -417,7 +417,7 @@ async def get_all_tags( @workflows_router.get("/counts_by_tag", operation_id="get_counts_by_tag") -async def get_counts_by_tag( +def get_counts_by_tag( current_user: CurrentUserOrDefault, tags: list[str] = Query(description="The tags to get counts for"), categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"), @@ -438,7 +438,7 @@ async def get_counts_by_tag( @workflows_router.get("/counts_by_category", operation_id="counts_by_category") -async def counts_by_category( +def counts_by_category( current_user: CurrentUserOrDefault, categories: list[WorkflowCategory] = Query(description="The categories to include"), has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"), @@ -461,7 +461,7 @@ async def counts_by_category( "/i/{workflow_id}/opened_at", operation_id="update_opened_at", ) -async def update_opened_at( +def update_opened_at( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to update"), ) -> None: diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index c0722c4bd1c..9b744bba99d 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -7,7 +7,6 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.security.utils import get_authorization_scheme_param @@ -16,6 +15,7 @@ from starlette.concurrency import run_in_threadpool from starlette.datastructures import Headers from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.middleware.gzip import GZipMiddleware, GZipResponder, IdentityResponder from starlette.types import ASGIApp, Message, Receive, Scope, Send import invokeai.frontend.web as web_dir @@ -159,6 +159,106 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): return response +# Response types worth compressing. Everything else is passed through untouched. +# +# This is an allowlist rather than a blocklist of media types on purpose: a type missing from +# this list only loses compression it would barely have benefited from, whereas a binary type +# missing from a blocklist costs real CPU on the event loop. The app serves a small, known set +# of compressible things — the UI bundle, the API's JSON, SVG icons. +COMPRESSIBLE_CONTENT_TYPES = ( + "text/", + "application/json", + "application/javascript", + "application/xml", + "application/xhtml+xml", + "application/manifest+json", + "image/svg+xml", +) + +# `text/` would otherwise match this, and compressing an event stream defeats its purpose by +# withholding events until the compressor flushes. Starlette excludes it by default too. +UNCOMPRESSIBLE_CONTENT_TYPES = ("text/event-stream",) + + +def _is_compressible(content_type: str) -> bool: + if content_type.startswith(UNCOMPRESSIBLE_CONTENT_TYPES): + return False + return content_type.startswith(COMPRESSIBLE_CONTENT_TYPES) + + +class _ContentTypeAwareGZipResponder(GZipResponder): + """Skips compression for response types that are already compressed. + + `content_type_is_excluded` is computed when the response starts and only read once the + body arrives, so widening it right after the base class has set it is enough — no need to + reimplement Starlette's streaming/pathsend handling. + """ + + async def send_with_compression(self, message: Message) -> None: + await super().send_with_compression(message) + if message["type"] == "http.response.start" and not self.content_type_is_excluded: + self.content_type_is_excluded = not _is_compressible( + Headers(raw=message["headers"]).get("content-type", "") + ) + + +class ContentTypeAwareGZipMiddleware(GZipMiddleware): + """GZip, but only for content types that actually compress. + + Starlette's GZipMiddleware compresses every response type except `text/event-stream`. The + gallery serves PNG, WebP and MP4 bytes, which are already compressed: a 3 MB PNG costs + ~52ms of event-loop time to gzip and comes back *larger* than it went in. With auto-switch + enabled the UI fetches the full image after every generated image, so that cost lands + repeatedly during a batch — exactly when the server can least afford to stall. + + Lowering `compresslevel` does not help here: on incompressible input, level 1 costs + essentially the same as level 9 because deflate still has to scan the data. It does help a + great deal on the compressible path — see `configure_gzip`. + """ + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + if "gzip" in Headers(scope=scope).get("Accept-Encoding", ""): + responder: ASGIApp = _ContentTypeAwareGZipResponder( + self.app, self.minimum_size, compresslevel=self.compresslevel + ) + else: + responder = IdentityResponder(self.app, self.minimum_size) + + await responder(scope, receive, send) + + +# Responses below this are not worth a compression pass; the gzip framing alone is ~20 bytes. +GZIP_MINIMUM_SIZE = 1000 + + +def configure_gzip(app: FastAPI, compresslevel: int) -> None: + """Install response compression, unless it is turned off. + + Compression runs on the event loop, so its cost is not paid by the requesting client alone — + it stalls every other request and every socket.io event for its duration. That makes the + level a real trade-off rather than a free win. + + Measured on the flat name list of a 200k-image library (8.48 MB of JSON): level 1 takes + 16.4ms and returns 6.1% of the input, level 9 takes 90.2ms and returns 5.7%. Level 9 costs + 5.5x the event-loop time for 0.4 percentage points of bandwidth, which is a poor deal for a + locally-served app. The default stays at 9 so behavior is unchanged for existing installs; + users who feel the stall on a large library can lower it. + + A `compresslevel` of 0 means "no compression". The middleware is then left out entirely + rather than installed at level 0, so responses skip the responder altogether instead of + being buffered and re-emitted as a stored-only gzip stream. Deployments behind a proxy that + already compresses (nginx, Caddy) want this, both to avoid the duplicated work and because + the proxy can compress off the event loop. + """ + if compresslevel <= 0: + return + app.add_middleware(ContentTypeAwareGZipMiddleware, minimum_size=GZIP_MINIMUM_SIZE, compresslevel=compresslevel) + + class RedirectRootWithQueryStringMiddleware(BaseHTTPMiddleware): """When a request is made to the root path with a query string, redirect to the root path without the query string. @@ -400,7 +500,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: expose_headers=["X-Refreshed-Token"], ) -app.add_middleware(GZipMiddleware, minimum_size=1000) +configure_gzip(app, app_config.gzip_compresslevel) # Include all routers diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index ae1e38e0ccc..24459dbd368 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -141,6 +141,7 @@ class InvokeAIAppConfig(BaseSettings): external_seedream_base_url: Base URL override for Seedream image generation. base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root. forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set. + gzip_compresslevel: GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses. """ _root: Optional[Path] = PrivateAttr(default=None) @@ -164,6 +165,7 @@ class InvokeAIAppConfig(BaseSettings): ssl_keyfile: Optional[Path] = Field(default=None, description="SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.") base_url: Optional[str] = Field(default=None, description="Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Required when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`); optional when the proxy strips it (set it anyway so openapi/docs URLs are correct). Leave unset when serving at the domain root. Normalized to a single leading slash with no trailing slash.") forwarded_allow_ips: str = Field(default="127.0.0.1", description="Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.") + gzip_compresslevel: int = Field(default=9, ge=0, le=9, description="GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses.") # MISC FEATURES log_tokenization: bool = Field(default=False, description="Enable logging of parsed prompt tokens.") diff --git a/invokeai/app/services/gallery/gallery_base.py b/invokeai/app/services/gallery/gallery_base.py index bd6591884de..a2679aba7a8 100644 --- a/invokeai/app/services/gallery/gallery_base.py +++ b/invokeai/app/services/gallery/gallery_base.py @@ -1,7 +1,12 @@ from abc import ABC, abstractmethod from typing import Optional -from invokeai.app.services.gallery.gallery_common import BoardMediaSummary, GalleryItem, GalleryItemNamesResult +from invokeai.app.services.gallery.gallery_common import ( + BoardMediaSummary, + GalleryItem, + GalleryItemNames, + GalleryItemNamesResult, +) from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection @@ -45,6 +50,27 @@ def list_item_names( ) -> GalleryItemNamesResult: """Returns ordered (kind, name) refs for optimistic UI / virtualized lists. + Deprecated — use :meth:`get_item_names`, which returns the same order without building + a model per row. + """ + pass + + @abstractmethod + def get_item_names( + self, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_date: Optional[str] = None, + ) -> GalleryItemNames: + """Returns the ordered flat name list for optimistic UI / virtualized lists. + `created_date` restricts the result to items created on the given ISO date — used by date-based virtual boards. """ diff --git a/invokeai/app/services/gallery/gallery_common.py b/invokeai/app/services/gallery/gallery_common.py index 85753c958c7..00a3befdb87 100644 --- a/invokeai/app/services/gallery/gallery_common.py +++ b/invokeai/app/services/gallery/gallery_common.py @@ -49,13 +49,31 @@ class GalleryItem(BaseModelExcludeNull): class GalleryItemNamesResult(BaseModel): - """Ordered list of gallery item references plus counts for optimistic UI.""" + """Ordered list of gallery item references plus counts for optimistic UI. + + Deprecated in favour of :class:`GalleryItemNames`. Wrapping every name in an object to + carry a `kind` discriminator costs ~800ms of model construction on a 200k-item library, + for a field callers derive from the filename extension anyway. + """ items: list[GalleryItemRef] = Field(description="Ordered list of (kind, name) references.") starred_count: int = Field(description="Number of starred items (when starred_first=True).") total_count: int = Field(description="Total number of items matching the query.") +class GalleryItemNames(BaseModel): + """Ordered flat list of gallery item names plus counts for optimistic UI. + + Names are polymorphic — images and videos are interleaved by `created_at`. The kind of a + given name is its file extension (`.mp4` is a video), which is how every consumer already + discriminates. Mirrors the shape of the image-only `ImageNamesResult`. + """ + + item_names: list[str] = Field(description="Ordered list of image and video names.") + starred_count: int = Field(description="Number of starred items (when starred_first=True).") + total_count: int = Field(description="Total number of items matching the query.") + + @dataclass(frozen=True) class BoardMediaSummary: cover_image_name: Optional[str] = None diff --git a/invokeai/app/services/gallery/gallery_default.py b/invokeai/app/services/gallery/gallery_default.py index a0f2fe1f397..602f61b5527 100644 --- a/invokeai/app/services/gallery/gallery_default.py +++ b/invokeai/app/services/gallery/gallery_default.py @@ -6,6 +6,7 @@ BoardMediaSummary, GalleryItem, GalleryItemKind, + GalleryItemNames, GalleryItemNamesResult, GalleryItemRef, ) @@ -100,19 +101,24 @@ def list_items( total=image_count + video_count, ) - def list_item_names( + def _query_name_rows( self, - starred_first: bool = True, - order_dir: SQLiteDirection = SQLiteDirection.Descending, - origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - user_id: Optional[str] = None, - is_admin: bool = False, - created_date: Optional[str] = None, - ) -> GalleryItemNamesResult: + starred_first: bool, + order_dir: SQLiteDirection, + origin: Optional[ResourceOrigin], + categories: Optional[list[ImageCategory]], + is_intermediate: Optional[bool], + board_id: Optional[str], + search_term: Optional[str], + user_id: Optional[str], + is_admin: bool, + created_date: Optional[str], + ) -> tuple[list[sqlite3.Row], int]: + """Runs the ordered name query and returns its rows plus the starred count. + + Shared by both name-list shapes so the deprecated `(kind, name)` variant and the flat + one can never drift apart in ordering or filtering. + """ image_half, image_params, _ = self._build_half( kind="image", origin=origin, @@ -158,9 +164,66 @@ def list_item_names( if starred_first: starred_count = sum(1 for r in rows if r["starred"]) + return rows, starred_count + + def list_item_names( + self, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_date: Optional[str] = None, + ) -> GalleryItemNamesResult: + rows, starred_count = self._query_name_rows( + starred_first=starred_first, + order_dir=order_dir, + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=user_id, + is_admin=is_admin, + created_date=created_date, + ) refs = [GalleryItemRef(kind=GalleryItemKind(row["kind"]), name=row["name"]) for row in rows] return GalleryItemNamesResult(items=refs, starred_count=starred_count, total_count=len(refs)) + def get_item_names( + self, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_date: Optional[str] = None, + ) -> GalleryItemNames: + rows, starred_count = self._query_name_rows( + starred_first=starred_first, + order_dir=order_dir, + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=user_id, + is_admin=is_admin, + created_date=created_date, + ) + # A list comprehension over the raw column, deliberately: building one model per row + # is what made the deprecated variant expensive. + names = [row["name"] for row in rows] + return GalleryItemNames(item_names=names, starred_count=starred_count, total_count=len(names)) + def get_dates( self, user_id: Optional[str] = None, diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 52b6d7bd75d..1872ab7759b 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -21,6 +21,7 @@ RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, + SessionQueueItemSummary, SessionQueueStatus, ) from invokeai.app.services.shared.graph import GraphExecutionState @@ -223,6 +224,11 @@ def get_queue_item_ids( """Gets all queue item ids that match the given parameters. If user_id is provided, only returns items for that user.""" pass + @abstractmethod + def get_queue_item_summaries_by_ids(self, queue_id: str, item_ids: list[int]) -> list[SessionQueueItemSummary]: + """Gets lightweight queue item summaries in the requested item ID order.""" + pass + @abstractmethod def get_queue_item(self, item_id: int) -> SessionQueueItem: """Gets a session queue item by ID for a given queue""" diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index d9535c47e00..5b72438ef22 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -313,6 +313,32 @@ def queue_item_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItem": ) +class SessionQueueItemSummary(BaseModel): + """Queue item fields needed to render the queue list.""" + + item_id: int = Field(description="The identifier of the session queue item") + created_at: Union[datetime.datetime, str] = Field(description="When this queue item was created") + status: QUEUE_ITEM_STATUS = Field(description="The status of this queue item") + device: Optional[str] = Field( + default=None, + description="The device that processed this queue item, e.g. 'cuda:1'", + ) + started_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was started") + completed_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was completed") + origin: str | None = Field(description="The origin of this queue item") + destination: str | None = Field(description="The destination of this queue item") + batch_id: str = Field(description="The ID of the batch associated with this queue item") + user_id: str = Field(description="The ID of the user who created this queue item") + user_display_name: Optional[str] = Field(description="The display name of the user who created this queue item") + user_email: Optional[str] = Field(description="The email of the user who created this queue item") + field_values: Optional[list[NodeFieldValue]] = Field(description="The batch field values used for this queue item") + + @classmethod + def queue_item_summary_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemSummary": + queue_item_dict["field_values"] = get_field_values(queue_item_dict) + return cls(**queue_item_dict) + + # endregion Queue Items # region Query Results diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 00e82b34064..1f1e17ea9c1 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -31,6 +31,7 @@ SessionQueueCountsByDestination, SessionQueueItem, SessionQueueItemNotFoundError, + SessionQueueItemSummary, SessionQueueStatus, TooManySessionsError, ValueToInsertTuple, @@ -1361,6 +1362,41 @@ def get_queue_item_ids( return ItemIdsResult(item_ids=item_ids, total_count=len(item_ids)) + def get_queue_item_summaries_by_ids(self, queue_id: str, item_ids: list[int]) -> list[SessionQueueItemSummary]: + if not item_ids: + return [] + + placeholders = ", ".join("?" for _ in item_ids) + with self._db.transaction() as cursor: + cursor.execute( + f"""--sql + SELECT + sq.item_id, + sq.created_at, + sq.status, + sq.device, + sq.started_at, + sq.completed_at, + sq.origin, + sq.destination, + sq.batch_id, + sq.user_id, + u.display_name AS user_display_name, + u.email AS user_email, + sq.field_values + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id + WHERE sq.queue_id = ? AND sq.item_id IN ({placeholders}) + """, + (queue_id, *item_ids), + ) + rows = cast(list[sqlite3.Row], cursor.fetchall()) + + summaries_by_id = { + row["item_id"]: SessionQueueItemSummary.queue_item_summary_from_dict(dict(row)) for row in rows + } + return [summaries_by_id[item_id] for item_id in item_ids if item_id in summaries_by_id] + def get_queue_status( self, queue_id: str, diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index add72e7ac55..8d423fe53bc 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -5869,8 +5869,9 @@ "get": { "tags": ["images"], "summary": "Get Image Names", - "description": "Gets ordered list of image names with metadata for optimistic updates", + "description": "Gets ordered list of image names with metadata for optimistic updates.\n\nDeprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved\nin one ordered list. This image-only endpoint predates the polymorphic gallery.", "operationId": "get_image_names", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -6918,8 +6919,9 @@ "get": { "tags": ["videos"], "summary": "Get Video Names", - "description": "Gets ordered list of video names with metadata for optimistic updates.", + "description": "Gets ordered list of video names with metadata for optimistic updates.\n\nDeprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved\nin one ordered list. This video-only endpoint predates the polymorphic gallery.", "operationId": "get_video_names", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -7421,12 +7423,184 @@ } } }, + "/api/v1/gallery/item_names": { + "get": { + "tags": ["gallery"], + "summary": "List Gallery Item Names", + "description": "Returns the ordered flat list of item names \u2014 used to drive virtualized gallery selection.\n\nNames are polymorphic: image and video names are interleaved by `created_at`. A name ending\nin `.mp4` is a video.", + "operationId": "list_gallery_item_names", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResourceOrigin" + }, + { + "type": "null" + } + ], + "description": "The origin of items to list.", + "title": "Origin" + }, + "description": "The origin of items to list." + }, + { + "name": "categories", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageCategory" + } + }, + { + "type": "null" + } + ], + "description": "The categories to include. Shared between images and videos.", + "title": "Categories" + }, + "description": "The categories to include. Shared between images and videos." + }, + { + "name": "is_intermediate", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to list intermediate items.", + "title": "Is Intermediate" + }, + "description": "Whether to list intermediate items." + }, + { + "name": "board_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The board id to filter by. Use 'none' to find items without a board.", + "title": "Board Id" + }, + "description": "The board id to filter by. Use 'none' to find items without a board." + }, + { + "name": "created_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Restrict to items created on this ISO date, e.g. '2026-03-18'. Used by date-based virtual boards.", + "title": "Created Date" + }, + "description": "Restrict to items created on this ISO date, e.g. '2026-03-18'. Used by date-based virtual boards." + }, + { + "name": "order_dir", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/SQLiteDirection", + "description": "The order of sort", + "default": "DESC" + }, + "description": "The order of sort" + }, + { + "name": "starred_first", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to sort by starred items first", + "default": true, + "title": "Starred First" + }, + "description": "Whether to sort by starred items first" + }, + { + "name": "search_term", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The term to search for", + "title": "Search Term" + }, + "description": "The term to search for" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GalleryItemNames" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/gallery/items/names": { "get": { "tags": ["gallery"], "summary": "Get Gallery Item Names", - "description": "Returns an ordered (kind, name) list \u2014 used to drive virtualized gallery selection.", + "description": "Returns an ordered (kind, name) list \u2014 used to drive virtualized gallery selection.\n\nDeprecated: use `GET /v1/gallery/item_names`, which returns the same order as a flat name\nlist. The `kind` discriminator here costs a model per row \u2014 ~800ms on a 200k-item library \u2014\nfor a value callers already derive from the file extension.", "operationId": "get_gallery_item_names", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -8229,8 +8403,9 @@ "get": { "tags": ["virtual_boards"], "summary": "List Virtual Board Image Names By Date", - "description": "Gets ordered image names for a specific date. Image-only; kept for API compatibility \u2014\nthe UI uses the polymorphic `/by_date/{date}/item_names` endpoint.", + "description": "Gets ordered image names for a specific date. Image-only.\n\nDeprecated: use `GET /v1/gallery/item_names?created_date=`, which covers images and\nvideos in one ordered list.", "operationId": "list_virtual_board_image_names_by_date", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -8339,8 +8514,9 @@ "get": { "tags": ["virtual_boards"], "summary": "List Virtual Board Item Names By Date", - "description": "Gets ordered polymorphic (image + video) item refs for a specific date.", + "description": "Gets ordered polymorphic (image + video) item refs for a specific date.\n\nDeprecated: use `GET /v1/gallery/item_names?created_date=`, which returns the same\norder as a flat name list instead of one model per item.", "operationId": "list_virtual_board_item_names_by_date", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -9382,6 +9558,68 @@ } } }, + "/api/v1/queue/{queue_id}/item_summaries_by_ids": { + "post": { + "tags": ["queue"], + "summary": "Get Queue Item Summaries By Ids", + "description": "Gets lightweight queue item summaries for specified IDs in requested order.", + "operationId": "get_queue_item_summaries_by_ids", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The queue id to perform this operation on", + "title": "Queue Id" + }, + "description": "The queue id to perform this operation on" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_get_queue_item_summaries_by_ids" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionQueueItemSummary" + }, + "title": "Response 200 Get Queue Item Summaries By Ids" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/queue/{queue_id}/processor/resume": { "put": { "tags": ["queue"], @@ -15662,7 +15900,7 @@ "anyOf": [ { "type": "string", - "format": "binary" + "contentMediaType": "application/octet-stream" }, { "type": "null" @@ -15821,6 +16059,21 @@ "required": ["image_names"], "title": "Body_get_images_by_names" }, + "Body_get_queue_item_summaries_by_ids": { + "properties": { + "item_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Item Ids", + "description": "Object containing list of queue item ids to fetch summaries for" + } + }, + "type": "object", + "required": ["item_ids"], + "title": "Body_get_queue_item_summaries_by_ids" + }, "Body_get_queue_items_by_item_ids": { "properties": { "item_ids": { @@ -15840,7 +16093,7 @@ "properties": { "file": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File", "description": "The file to import" } @@ -15930,7 +16183,7 @@ "properties": { "image": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "Image", "description": "The image file to upload" } @@ -15973,7 +16226,7 @@ "properties": { "image": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "Image" } }, @@ -15987,7 +16240,7 @@ "anyOf": [ { "type": "string", - "format": "binary" + "contentMediaType": "application/octet-stream" }, { "type": "null" @@ -16033,7 +16286,7 @@ "properties": { "file": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File" }, "resize_to": { @@ -16070,7 +16323,7 @@ "properties": { "file": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File" }, "metadata": { @@ -32818,6 +33071,32 @@ "title": "GalleryItemKind", "description": "Discriminator for polymorphic gallery items." }, + "GalleryItemNames": { + "properties": { + "item_names": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Item Names", + "description": "Ordered list of image and video names." + }, + "starred_count": { + "type": "integer", + "title": "Starred Count", + "description": "Number of starred items (when starred_first=True)." + }, + "total_count": { + "type": "integer", + "title": "Total Count", + "description": "Total number of items matching the query." + } + }, + "type": "object", + "required": ["item_names", "starred_count", "total_count"], + "title": "GalleryItemNames", + "description": "Ordered flat list of gallery item names plus counts for optimistic UI.\n\nNames are polymorphic \u2014 images and videos are interleaved by `created_at`. The kind of a\ngiven name is its file extension (`.mp4` is a video), which is how every consumer already\ndiscriminates. Mirrors the shape of the image-only `ImageNamesResult`." + }, "GalleryItemNamesResult": { "properties": { "items": { @@ -32842,7 +33121,7 @@ "type": "object", "required": ["items", "starred_count", "total_count"], "title": "GalleryItemNamesResult", - "description": "Ordered list of gallery item references plus counts for optimistic UI." + "description": "Ordered list of gallery item references plus counts for optimistic UI.\n\nDeprecated in favour of :class:`GalleryItemNames`. Wrapping every name in an object to\ncarry a `kind` discriminator costs ~800ms of model construction on a 200k-item library,\nfor a field callers derive from the filename extension anyway." }, "GalleryItemRef": { "properties": { @@ -47839,6 +48118,14 @@ "description": "Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.", "default": "127.0.0.1" }, + "gzip_compresslevel": { + "type": "integer", + "maximum": 9.0, + "minimum": 0.0, + "title": "Gzip Compresslevel", + "description": "GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses.", + "default": 9 + }, "log_tokenization": { "type": "boolean", "title": "Log Tokenization", @@ -48402,7 +48689,7 @@ "additionalProperties": false, "type": "object", "title": "InvokeAIAppConfig", - "description": "Invoke's global app configuration.\n\nTypically, you won't need to interact with this class directly. Instead, use the `get_config` function from `invokeai.app.services.config` to get a singleton config object.\n\nAttributes:\n host: IP address to bind to. Use `0.0.0.0` to serve to your local network.\n port: Port to bind to.\n allow_origins: Allowed CORS origins.\n allow_credentials: Allow CORS credentials.\n allow_methods: Methods allowed for CORS.\n allow_headers: Headers allowed for CORS.\n ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n log_tokenization: Enable logging of parsed prompt tokens.\n patchmatch: Enable patchmatch inpaint code.\n models_dir: Path to the models directory.\n convert_cache_dir: Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).\n download_cache_dir: Path to the directory that contains dynamically downloaded models.\n legacy_conf_dir: Path to directory of legacy checkpoint config files.\n db_dir: Path to InvokeAI databases directory.\n outputs_dir: Path to directory for outputs.\n image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.
Valid values: `flat`, `date`, `type`, `hash`\n custom_nodes_dir: Path to directory for custom nodes.\n style_presets_dir: Path to directory for style presets.\n workflow_thumbnails_dir: Path to directory for workflow thumbnails.\n log_handlers: Log handler. Valid options are \"console\", \"file=\", \"syslog=path|address:host:port\", \"http=\".\n log_format: Log format. Use \"plain\" for text-only, \"color\" for colorized output, \"legacy\" for 2.3-style logging and \"syslog\" for syslog-style.
Valid values: `plain`, `color`, `syslog`, `legacy`\n log_level: Emit logging messages at this level or higher.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n log_sql: Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.\n log_level_network: Log level for network-related messages. 'info' and 'debug' are very verbose.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n use_memory_db: Use in-memory database. Useful for development.\n dev_reload: Automatically reload when Python sources are changed. Does not reload node definitions.\n profile_graphs: Enable graph profiling using `cProfile`.\n profile_prefix: An optional prefix for profile output files.\n profiles_dir: Path to profiles output directory.\n max_cache_ram_gb: The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.\n max_cache_vram_gb: The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.\n log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.\n model_cache_keep_alive_min: How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.\n device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.\n enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.\n keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.\n ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.\n pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.\n device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.
Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)\n precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.
Valid values: `auto`, `float16`, `bfloat16`, `float32`\n sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.\n attention_type: Attention type.
Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`\n attention_slice_size: Slice size, valid when attention_type==\"sliced\".
Valid values: `auto`, `balanced`, `max`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`\n force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).\n pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.\n max_queue_size: Maximum number of items in the session queue.\n session_queue_mode: Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.
Valid values: `FIFO`, `round_robin`\n clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`.\n max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.\n allow_nodes: List of nodes to allow. Omit to allow all.\n deny_nodes: List of nodes to deny. Omit to deny none.\n node_cache_size: How many cached nodes to keep in memory.\n hashing_algorithm: Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.
Valid values: `blake3_multi`, `blake3_single`, `random`, `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `blake2b`, `blake2s`, `sha3_224`, `sha3_256`, `sha3_384`, `sha3_512`, `shake_128`, `shake_256`\n remote_api_tokens: List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.\n scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.\n unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.\n allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.\n multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.\n strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.\n external_alibabacloud_api_key: API key for Alibaba Cloud DashScope image generation.\n external_alibabacloud_base_url: Base URL override for Alibaba Cloud DashScope image generation.\n external_gemini_api_key: API key for Gemini image generation.\n external_openai_api_key: API key for OpenAI image generation.\n external_gemini_base_url: Base URL override for Gemini image generation.\n external_openai_base_url: Base URL override for OpenAI image generation.\n external_seedream_api_key: API key for Seedream image generation.\n external_seedream_base_url: Base URL override for Seedream image generation.\n base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root.\n forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set." + "description": "Invoke's global app configuration.\n\nTypically, you won't need to interact with this class directly. Instead, use the `get_config` function from `invokeai.app.services.config` to get a singleton config object.\n\nAttributes:\n host: IP address to bind to. Use `0.0.0.0` to serve to your local network.\n port: Port to bind to.\n allow_origins: Allowed CORS origins.\n allow_credentials: Allow CORS credentials.\n allow_methods: Methods allowed for CORS.\n allow_headers: Headers allowed for CORS.\n ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n log_tokenization: Enable logging of parsed prompt tokens.\n patchmatch: Enable patchmatch inpaint code.\n models_dir: Path to the models directory.\n convert_cache_dir: Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).\n download_cache_dir: Path to the directory that contains dynamically downloaded models.\n legacy_conf_dir: Path to directory of legacy checkpoint config files.\n db_dir: Path to InvokeAI databases directory.\n outputs_dir: Path to directory for outputs.\n image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.
Valid values: `flat`, `date`, `type`, `hash`\n custom_nodes_dir: Path to directory for custom nodes.\n style_presets_dir: Path to directory for style presets.\n workflow_thumbnails_dir: Path to directory for workflow thumbnails.\n log_handlers: Log handler. Valid options are \"console\", \"file=\", \"syslog=path|address:host:port\", \"http=\".\n log_format: Log format. Use \"plain\" for text-only, \"color\" for colorized output, \"legacy\" for 2.3-style logging and \"syslog\" for syslog-style.
Valid values: `plain`, `color`, `syslog`, `legacy`\n log_level: Emit logging messages at this level or higher.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n log_sql: Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.\n log_level_network: Log level for network-related messages. 'info' and 'debug' are very verbose.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n use_memory_db: Use in-memory database. Useful for development.\n dev_reload: Automatically reload when Python sources are changed. Does not reload node definitions.\n profile_graphs: Enable graph profiling using `cProfile`.\n profile_prefix: An optional prefix for profile output files.\n profiles_dir: Path to profiles output directory.\n max_cache_ram_gb: The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.\n max_cache_vram_gb: The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.\n log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.\n model_cache_keep_alive_min: How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.\n device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.\n enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.\n keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.\n ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.\n pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.\n device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.
Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)\n precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.
Valid values: `auto`, `float16`, `bfloat16`, `float32`\n sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.\n attention_type: Attention type.
Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`\n attention_slice_size: Slice size, valid when attention_type==\"sliced\".
Valid values: `auto`, `balanced`, `max`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`\n force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).\n pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.\n max_queue_size: Maximum number of items in the session queue.\n session_queue_mode: Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.
Valid values: `FIFO`, `round_robin`\n clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`.\n max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.\n allow_nodes: List of nodes to allow. Omit to allow all.\n deny_nodes: List of nodes to deny. Omit to deny none.\n node_cache_size: How many cached nodes to keep in memory.\n hashing_algorithm: Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.
Valid values: `blake3_multi`, `blake3_single`, `random`, `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `blake2b`, `blake2s`, `sha3_224`, `sha3_256`, `sha3_384`, `sha3_512`, `shake_128`, `shake_256`\n remote_api_tokens: List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.\n scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.\n unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.\n allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.\n multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.\n strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.\n external_alibabacloud_api_key: API key for Alibaba Cloud DashScope image generation.\n external_alibabacloud_base_url: Base URL override for Alibaba Cloud DashScope image generation.\n external_gemini_api_key: API key for Gemini image generation.\n external_openai_api_key: API key for OpenAI image generation.\n external_gemini_base_url: Base URL override for Gemini image generation.\n external_openai_base_url: Base URL override for OpenAI image generation.\n external_seedream_api_key: API key for Seedream image generation.\n external_seedream_base_url: Base URL override for Seedream image generation.\n base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root.\n forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.\n gzip_compresslevel: GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses." }, "InvokeAIAppConfigWithSetFields": { "properties": { @@ -77556,6 +77843,168 @@ "title": "SessionQueueItem", "description": "Session queue item without the full graph. Used for serialization." }, + "SessionQueueItemSummary": { + "properties": { + "item_id": { + "type": "integer", + "title": "Item Id", + "description": "The identifier of the session queue item" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "string" + } + ], + "title": "Created At", + "description": "When this queue item was created" + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"], + "title": "Status", + "description": "The status of this queue item" + }, + "device": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device", + "description": "The device that processed this queue item, e.g. 'cuda:1'" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At", + "description": "When this queue item was started" + }, + "completed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completed At", + "description": "When this queue item was completed" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origin", + "description": "The origin of this queue item" + }, + "destination": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination", + "description": "The destination of this queue item" + }, + "batch_id": { + "type": "string", + "title": "Batch Id", + "description": "The ID of the batch associated with this queue item" + }, + "user_id": { + "type": "string", + "title": "User Id", + "description": "The ID of the user who created this queue item" + }, + "user_display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Display Name", + "description": "The display name of the user who created this queue item" + }, + "user_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Email", + "description": "The email of the user who created this queue item" + }, + "field_values": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/NodeFieldValue" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Field Values", + "description": "The batch field values used for this queue item" + } + }, + "type": "object", + "required": [ + "item_id", + "created_at", + "status", + "started_at", + "completed_at", + "origin", + "destination", + "batch_id", + "user_id", + "user_display_name", + "user_email", + "field_values" + ], + "title": "SessionQueueItemSummary", + "description": "Queue item fields needed to render the queue list." + }, "SessionQueueStatus": { "properties": { "queue_id": { @@ -85026,6 +85475,13 @@ "type": { "type": "string", "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" } }, "type": "object", diff --git a/invokeai/frontend/web/scripts/typegen.js b/invokeai/frontend/web/scripts/typegen.js index 87c00a28833..f526b149466 100644 --- a/invokeai/frontend/web/scripts/typegen.js +++ b/invokeai/frontend/web/scripts/typegen.js @@ -24,7 +24,13 @@ async function generateTypes(schema) { const types = await openapiTS(schema, { exportType: true, transform: (schemaObject) => { - if ('format' in schemaObject && schemaObject.format === 'binary') { + // File upload fields. FastAPI emitted `format: binary` up to 0.129 and switched to the + // OpenAPI 3.1 form `contentMediaType: application/octet-stream` in 0.130 — both must map + // to `Blob`, or upload call sites silently start typing their `File` argument as `string`. + const isBinary = + ('format' in schemaObject && schemaObject.format === 'binary') || + ('contentMediaType' in schemaObject && schemaObject.contentMediaType === 'application/octet-stream'); + if (isBinary) { return schemaObject.nullable ? ts.factory.createUnionTypeNode([BLOB, NULL]) : BLOB; } if (schemaObject.title === 'MetadataField') { diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts index a53b5e6767f..aa1a03986b6 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -1,4 +1,4 @@ -import { createAction, isAnyOf } from '@reduxjs/toolkit'; +import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/store'; import { noop } from 'es-toolkit'; import { setInfillMethod } from 'features/controlLayers/store/paramsSlice'; @@ -6,7 +6,6 @@ import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors' import { imageSelected } from 'features/gallery/store/gallerySlice'; import { appInfoApi } from 'services/api/endpoints/appInfo'; import { galleryApi } from 'services/api/endpoints/gallery'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; export const appStarted = createAction('app/appStarted'); @@ -31,20 +30,14 @@ export const addAppStartedListener = (startAppListening: AppStartListening) => { .catch(noop); // Ensure a gallery item is selected when we load the first board. The grid is fed by the - // polymorphic `getGalleryItemNames` endpoint (image + video names interleaved by date), + // polymorphic `listGalleryItemNames` endpoint (image + video names interleaved by date), // so that's what we wait on — the older `getImageNames` is no longer dispatched and would - // time out forever. + // time out forever. Date-based virtual boards go through the same endpoint. // // The effect must be async and await take() so that RTK keeps the listener's AbortController // alive until the query resolves; a synchronous effect causes the controller to be aborted // immediately after the effect returns, before any network response arrives. - const firstLoad = await take( - isAnyOf( - galleryApi.endpoints.getGalleryItemNames.matchFulfilled, - virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.matchFulfilled - ), - 5000 - ); + const firstLoad = await take(galleryApi.endpoints.listGalleryItemNames.matchFulfilled, 5000); if (firstLoad === null) { // timeout or cancelled return; @@ -54,9 +47,9 @@ export const addAppStartedListener = (startAppListening: AppStartListening) => { if (selectedItem) { return; } - const firstItem = payload.items[0]; - if (firstItem) { - dispatch(imageSelected(firstItem.name)); + const firstItemName = payload.item_names[0]; + if (firstItemName) { + dispatch(imageSelected(firstItemName)); } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 65d2af4437b..05dd8e9f208 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,10 +1,8 @@ import { isAnyOf } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/store'; -import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; +import { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; -import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; import { galleryApi } from 'services/api/endpoints/gallery'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => { startAppListening({ @@ -18,23 +16,11 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) return; } - const state = getState(); - - const board_id = selectSelectedBoardId(state); - - // The grid is now backed by the polymorphic getGalleryItemNames endpoint (the legacy + // The grid is backed by the polymorphic listGalleryItemNames endpoint (the legacy // getImageNames query is no longer dispatched), so the auto-select probe must read its - // cache or it will time out and clear the user's selection on every board switch. - const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id }; - const selectQuery = isVirtualBoardId(board_id) - ? virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.select({ - date: getDateFromVirtualBoardId(board_id), - categories: queryArgs.categories ?? undefined, - search_term: queryArgs.search_term || undefined, - order_dir: queryArgs.order_dir, - starred_first: queryArgs.starred_first, - }) - : galleryApi.endpoints.getGalleryItemNames.select(queryArgs); + // cache or it will time out and clear the user's selection on every board switch. The + // selector already maps a virtual board id to its `created_date` filter. + const selectQuery = galleryApi.endpoints.listGalleryItemNames.select(selectGalleryItemNamesQueryArgs(getState())); // wait until the board has some items - maybe it already has some from a previous fetch // must use getState() to ensure we do not have stale state const isSuccess = await condition(() => selectQuery(getState()).isSuccess, 5000); @@ -45,11 +31,9 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) } // the board was just changed - we can select the first gallery item (image or video) - const items = selectQuery(getState()).data?.items; - - const itemToSelect = items && items.length > 0 ? (items[0]?.name ?? null) : null; + const itemNames = selectQuery(getState()).data?.item_names; - dispatch(imageSelected(itemToSelect)); + dispatch(imageSelected(itemNames?.[0] ?? null)); }, }); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index b62a8b01c8d..7a33001384d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -3,7 +3,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; import { getVideoPrefetchOptions, useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; -import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import type { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, selectImageToCompare, @@ -42,7 +42,7 @@ import { scrollIntoView } from './scrollIntoView'; import { useGalleryImageNames } from './use-gallery-image-names'; import { useScrollableGallery } from './useScrollableGallery'; -type ListImageNamesQueryArgs = ReturnType; +type ListImageNamesQueryArgs = ReturnType; type GridContext = { queryArgs: ListImageNamesQueryArgs; diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts index eff9123cc61..a42ae3d3122 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts @@ -1,34 +1,38 @@ /** - * Pins the polymorphic name-flattening used by `useGalleryImageNames` for both regular boards - * and date-based virtual boards. + * Pins the translation of a selected board into name-list query args. * - * The bug (PR #9163 review): virtual boards were image-only — selecting a virtual date fetched - * from the legacy image_names endpoint, so videos created on that date never appeared. The hook - * now consumes the by-date item_names endpoint, which returns the same (kind, name) refs as the - * regular gallery names endpoint, and this shared mapper must keep video refs in the flat list. - * (The server-side guarantee that a date query returns video refs is pinned by - * tests/app/routers/test_virtual_boards.py.) + * A virtual board is a date, not a board row. Regular boards and virtual dates now share one + * endpoint (`listGalleryItemNames`), so the only thing keeping virtual dates working is that + * the id is converted into a `created_date` filter and *not* forwarded as `board_id` — the + * backend would filter on a board that does not exist and return an empty gallery. + * + * The original bug this area guards (PR #9163 review): virtual boards were image-only, so + * videos created on that date never appeared. The server-side half of that guarantee is pinned + * by tests/app/routers/test_virtual_boards.py. */ -import type { GalleryItemRef } from 'services/api/types'; +import { createStore } from 'app/store/store'; +import { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { describe, expect, it } from 'vitest'; -import { itemRefsToNames } from './use-gallery-image-names'; +describe('selectGalleryItemNamesQueryArgs', () => { + it('converts a virtual board id into a created_date filter', () => { + const store = createStore(); + store.dispatch(boardIdSelected({ boardId: 'by_date:2026-07-26' })); + + const args = selectGalleryItemNamesQueryArgs(store.getState()); -describe('itemRefsToNames', () => { - it('keeps video refs interleaved with images, preserving order', () => { - const items: GalleryItemRef[] = [ - { kind: 'image', name: 'newest.png' }, - { kind: 'video', name: 'middle.mp4' }, - { kind: 'image', name: 'oldest.png' }, - ]; - expect(itemRefsToNames(items)).toEqual(['newest.png', 'middle.mp4', 'oldest.png']); + expect(args.created_date).toBe('2026-07-26'); + expect(args.board_id).toBeUndefined(); }); - it('handles a video-only list (video-only virtual date)', () => { - const items: GalleryItemRef[] = [ - { kind: 'video', name: 'a.mp4' }, - { kind: 'video', name: 'b.mp4' }, - ]; - expect(itemRefsToNames(items)).toEqual(['a.mp4', 'b.mp4']); + it('passes a regular board id through untouched', () => { + const store = createStore(); + store.dispatch(boardIdSelected({ boardId: 'some-board-uuid' })); + + const args = selectGalleryItemNamesQueryArgs(store.getState()); + + expect(args.board_id).toBe('some-board-uuid'); + expect(args.created_date).toBeUndefined(); }); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts index 4749dc9de8b..d4275b39d56 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts @@ -1,12 +1,7 @@ -import { skipToken } from '@reduxjs/toolkit/query'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; -import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; -import { useMemo } from 'react'; -import { useGetGalleryItemNamesQuery } from 'services/api/endpoints/gallery'; -import { useGetVirtualBoardItemNamesByDateQuery } from 'services/api/endpoints/virtual_boards'; -import type { GalleryItemRef } from 'services/api/types'; +import { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { useListGalleryItemNamesQuery } from 'services/api/endpoints/gallery'; import { useDebounce } from 'use-debounce'; const selectFromGalleryItemNamesResult = ({ @@ -14,11 +9,11 @@ const selectFromGalleryItemNamesResult = ({ isLoading, isFetching, }: { - currentData?: { items: GalleryItemRef[] }; + currentData?: { item_names: string[] }; isLoading: boolean; isFetching: boolean; }) => ({ - items: currentData?.items ?? (EMPTY_ARRAY as GalleryItemRef[]), + imageNames: currentData?.item_names ?? (EMPTY_ARRAY as string[]), isLoading, isFetching, }); @@ -28,56 +23,19 @@ const galleryQueryOptions = { selectFromResult: selectFromGalleryItemNamesResult, }; -/** - * Flattens polymorphic (kind, name) refs into the ordered name list consumed by the gallery - * grid and navigation hotkeys. Video refs must pass through untouched — regular boards and - * date-based virtual boards both contain them. Exported for tests. - */ -export const itemRefsToNames = (items: GalleryItemRef[]): string[] => items.map((ref) => ref.name); - /** * Returns the ordered flat list of gallery item names. Names are polymorphic — both image and * video names appear in the same list, interleaved by created_at. Callers that need to know the * kind of a particular name use `isVideoName` from `features/gallery/store/types`. * - * Virtual boards (date-based) go through their own by-date endpoint, which returns the same - * polymorphic (kind, name) refs as the regular gallery names endpoint. + * Regular boards and date-based virtual boards share one endpoint; the selector translates a + * virtual board id into the `created_date` filter. */ export const useGalleryImageNames = () => { - const selectedBoardId = useAppSelector(selectSelectedBoardId); - const _imageQueryArgs = useAppSelector(selectGetImageNamesQueryArgs); - const [imageQueryArgs] = useDebounce(_imageQueryArgs, 300); - const isVirtual = isVirtualBoardId(selectedBoardId); - - // The polymorphic gallery names endpoint shares the same filter args as the image names - // endpoint (board_id, categories, search_term, order_dir, starred_first, is_intermediate). - const galleryResult = useGetGalleryItemNamesQuery(isVirtual ? skipToken : imageQueryArgs, galleryQueryOptions); - - const date = isVirtual ? getDateFromVirtualBoardId(selectedBoardId) : ''; - const virtualResult = useGetVirtualBoardItemNamesByDateQuery( - isVirtual - ? { - date, - categories: imageQueryArgs.categories ?? undefined, - search_term: imageQueryArgs.search_term || undefined, - order_dir: imageQueryArgs.order_dir, - starred_first: imageQueryArgs.starred_first, - } - : skipToken, - galleryQueryOptions - ); + const _queryArgs = useAppSelector(selectGalleryItemNamesQueryArgs); + const [queryArgs] = useDebounce(_queryArgs, 300); - // Flat names + isLoading exposed for backward compatibility with the existing callers (paged - // grid, search, navigation hotkeys). The kind is recoverable from the filename extension. - const imageNames = useMemo(() => { - const items = isVirtual ? virtualResult.items : galleryResult.items; - return itemRefsToNames(items); - }, [isVirtual, virtualResult.items, galleryResult.items]); + const { imageNames, isLoading, isFetching } = useListGalleryItemNamesQuery(queryArgs, galleryQueryOptions); - return { - imageNames, - isLoading: isVirtual ? virtualResult.isLoading : galleryResult.isLoading, - isFetching: isVirtual ? virtualResult.isFetching : galleryResult.isFetching, - queryArgs: imageQueryArgs, - }; + return { imageNames, isLoading, isFetching, queryArgs }; }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index aad849fdb59..54c65c541ea 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -1,8 +1,13 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { GetImageNamesArgs, ListBoardsArgs } from 'services/api/types'; +import { + ASSETS_CATEGORIES, + getDateFromVirtualBoardId, + IMAGE_CATEGORIES, + isVirtualBoardId, +} from 'features/gallery/store/types'; +import type { GetImageNamesArgs, ListBoardsArgs, ListGalleryItemNamesArgs } from 'services/api/types'; export const selectFirstSelectedItem = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedItem = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); @@ -48,6 +53,25 @@ export const selectGetImageNamesQueryArgs = createMemoizedSelector( }) ); +/** + * Query args for the polymorphic name list the gallery grid runs off. + * + * A virtual board is a date, not a board: its id carries the date and there is no board row to + * filter on. Translating it to `created_date` here keeps that translation in one place — every + * consumer of the name list (grid, range selection, auto-select probes) shares this selector, + * so none of them can disagree about the cache key. + */ +export const selectGalleryItemNamesQueryArgs = createMemoizedSelector( + [selectGetImageNamesQueryArgs], + (args): ListGalleryItemNamesArgs => { + if (args.board_id && isVirtualBoardId(args.board_id)) { + const { board_id: _virtualBoardId, ...rest } = args; + return { ...rest, created_date: getDateFromVirtualBoardId(args.board_id) }; + } + return args; + } +); + export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, (gallery) => gallery.autoAssignBoardOnClick diff --git a/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts b/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts index f07a251b1bf..aa935c88dae 100644 --- a/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts +++ b/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts @@ -1,18 +1,16 @@ import type { AppGetState } from 'app/store/store'; -import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; import { galleryApi } from 'services/api/endpoints/gallery'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; -import type { GetGalleryItemNamesArgs } from 'services/api/types'; +import type { ListGalleryItemNamesArgs } from 'services/api/types'; -import { selectGetImageNamesQueryArgs } from './gallerySelectors'; +import { selectGalleryItemNamesQueryArgs } from './gallerySelectors'; /** * Returns the names (in display order) of the currently-cached gallery item list. * - * The grid renders via the polymorphic ``getGalleryItemNames`` endpoint, which returns a - * mixed image+video list. Range-selection click handlers (shift-click for ranges, ctrl-click - * for discontiguous selection) need that ordered list to compute the items between two - * clicks. + * The grid renders via the polymorphic ``listGalleryItemNames`` endpoint, which returns a + * mixed image+video list — regular boards and date-based virtual boards alike. Range-selection + * click handlers (shift-click for ranges, ctrl-click for discontiguous selection) need that + * ordered list to compute the items between two clicks. * * We look up the cache entry whose args match the gallery's current query args. RTK Query * keeps recently-used entries warm (60s default ``keepUnusedDataFor``), so after a board @@ -23,68 +21,32 @@ import { selectGetImageNamesQueryArgs } from './gallerySelectors'; * forced a refetch. */ export const selectCachedGalleryItemNames = (state: ReturnType): string[] => { - const args = selectGetImageNamesQueryArgs(state); - if (args.board_id && isVirtualBoardId(args.board_id)) { - const virtualArgs = { - date: getDateFromVirtualBoardId(args.board_id), - categories: args.categories ?? undefined, - search_term: args.search_term || undefined, - order_dir: args.order_dir, - starred_first: args.starred_first, - }; - const virtual = virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.select(virtualArgs)(state).data; - if (virtual) { - return virtual.items.map((ref) => ref.name); - } - const entries = virtualBoardsApi.util.selectInvalidatedBy(state, ['GalleryItemNameList']); - let mostRecent: - | { - names: string[]; - fulfilledTimeStamp: number; - } - | undefined; - for (const entry of entries) { - if (entry.endpointName !== 'getVirtualBoardItemNamesByDate') { - continue; - } - const entryArgs = entry.originalArgs as typeof virtualArgs; - if (entryArgs.date !== virtualArgs.date) { - continue; - } - const query = virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.select(entryArgs)(state); - if (query.data && (query.fulfilledTimeStamp ?? 0) >= (mostRecent?.fulfilledTimeStamp ?? -1)) { - mostRecent = { - names: query.data.items.map((ref) => ref.name), - fulfilledTimeStamp: query.fulfilledTimeStamp ?? 0, - }; - } - } - return mostRecent?.names ?? []; - } + const args = selectGalleryItemNamesQueryArgs(state); // Exact match: the entry the grid is actively subscribed to. This is the common case. - const exact = galleryApi.endpoints.getGalleryItemNames.select(args)(state).data; + const exact = galleryApi.endpoints.listGalleryItemNames.select(args)(state).data; if (exact) { - return exact.items.map((ref) => ref.name); + return exact.item_names; } // Debounce window: the grid hook debounces its args by ~300ms, so for a moment after the - // user changes a filter the cache key may not match Redux yet. Best-effort fallback to any - // cached entry on the same board so range selection still feels responsive — but do not - // silently fall back to an unrelated board's entry, which was the bug. + // user changes a filter the cache key may not match Redux yet. Best-effort fallback to the + // most recent cached entry for the same board or date, so range selection still feels + // responsive — but do not silently fall back to an unrelated board's entry, which was the bug. const entries = galleryApi.util.selectInvalidatedBy(state, ['GalleryItemNameList']); + let mostRecent: { names: string[]; fulfilledTimeStamp: number } | undefined; for (const entry of entries) { - if (entry.endpointName !== 'getGalleryItemNames') { + if (entry.endpointName !== 'listGalleryItemNames') { continue; } - const entryArgs = entry.originalArgs as GetGalleryItemNamesArgs | undefined; - if (!entryArgs || entryArgs.board_id !== args.board_id) { + const entryArgs = entry.originalArgs as ListGalleryItemNamesArgs | undefined; + if (!entryArgs || entryArgs.board_id !== args.board_id || entryArgs.created_date !== args.created_date) { continue; } - const data = galleryApi.endpoints.getGalleryItemNames.select(entryArgs)(state).data; - if (data) { - return data.items.map((ref) => ref.name); + const query = galleryApi.endpoints.listGalleryItemNames.select(entryArgs)(state); + if (query.data && (query.fulfilledTimeStamp ?? 0) >= (mostRecent?.fulfilledTimeStamp ?? -1)) { + mostRecent = { names: query.data.item_names, fulfilledTimeStamp: query.fulfilledTimeStamp ?? 0 }; } } - return []; + return mostRecent?.names ?? []; }; /** diff --git a/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts b/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts index e66b4870425..d4c3d94db02 100644 --- a/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts +++ b/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { createStore } from 'app/store/store'; import { boardIdSelected, searchTermChanged } from 'features/gallery/store/gallerySlice'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; +import { galleryApi } from 'services/api/endpoints/gallery'; import { describe, expect, it } from 'vitest'; import { selectCachedGalleryItemNames } from './selectCachedGalleryItemNames'; @@ -27,29 +27,28 @@ describe('virtual board gallery consumers', () => { ['range selection', './selectCachedGalleryItemNames.ts'], ['board auto-selection', '../../../app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts'], ['initial board auto-selection', '../../../app/store/middleware/listenerMiddleware/listeners/appStarted.ts'], - ])('%s reads the virtual-board item-name cache', (_label, relativePath) => { + ])('%s reads the polymorphic item-name cache', (_label, relativePath) => { const source = readSource(relativePath); - expect(source).toContain('getVirtualBoardItemNamesByDate'); + // Regular boards and virtual dates share one endpoint; a consumer that reached for a + // different cache would silently see an empty list on virtual dates. + expect(source).toContain('listGalleryItemNames'); }); it('keeps the prior virtual-board cache available during filter debounce', async () => { const store = createStore(); store.dispatch(boardIdSelected({ boardId: 'by_date:2026-07-26' })); await store.dispatch( - virtualBoardsApi.util.upsertQueryData( - 'getVirtualBoardItemNamesByDate', + galleryApi.util.upsertQueryData( + 'listGalleryItemNames', { - date: '2026-07-26', + created_date: '2026-07-26', categories: ['general'], order_dir: 'DESC', starred_first: true, + is_intermediate: false, }, - { - items: [{ name: 'cached.mp4', kind: 'video' }], - starred_count: 0, - total_count: 1, - } + { item_names: ['cached.mp4'], starred_count: 0, total_count: 1 } ) ); @@ -62,39 +61,33 @@ describe('virtual board gallery consumers', () => { const store = createStore(); store.dispatch(boardIdSelected({ boardId: 'by_date:2026-07-26' })); await store.dispatch( - virtualBoardsApi.util.upsertQueryData( - 'getVirtualBoardItemNamesByDate', + galleryApi.util.upsertQueryData( + 'listGalleryItemNames', { - date: '2026-07-26', + created_date: '2026-07-26', categories: ['general'], search_term: 'older filter', order_dir: 'DESC', starred_first: true, + is_intermediate: false, }, - { - items: [{ name: 'older.mp4', kind: 'video' }], - starred_count: 0, - total_count: 1, - } + { item_names: ['older.mp4'], starred_count: 0, total_count: 1 } ) ); await new Promise((resolve) => { setTimeout(resolve, 5); }); await store.dispatch( - virtualBoardsApi.util.upsertQueryData( - 'getVirtualBoardItemNamesByDate', + galleryApi.util.upsertQueryData( + 'listGalleryItemNames', { - date: '2026-07-26', + created_date: '2026-07-26', categories: ['general'], order_dir: 'DESC', starred_first: true, + is_intermediate: false, }, - { - items: [{ name: 'active.mp4', kind: 'video' }], - starred_count: 0, - total_count: 1, - } + { item_names: ['active.mp4'], starred_count: 0, total_count: 1 } ) ); diff --git a/invokeai/frontend/web/src/services/api/endpoints/gallery.ts b/invokeai/frontend/web/src/services/api/endpoints/gallery.ts index 5b3dd85493e..aa9eb95b51e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/gallery.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/gallery.ts @@ -1,12 +1,13 @@ import type { - GetGalleryItemNamesArgs, - GetGalleryItemNamesResult, + ListGalleryItemNamesArgs, + ListGalleryItemNamesResult, ListGalleryItemsArgs, ListGalleryItemsResponse, } from 'services/api/types'; import { getListGalleryItemsUrl } from 'services/api/util'; import stableHash from 'stable-hash'; +import type { ApiTagDescription } from '..'; import { api, buildV1Url } from '..'; /** @@ -34,25 +35,37 @@ export const galleryApi = api.injectEndpoints({ }), /** - * Ordered (kind, name) refs for virtualized selection. The gallery grid's name list and - * keyboard navigation use this — the flat string list is derived by mapping items to `name`. + * Ordered flat name list for virtualized selection — the gallery grid and keyboard + * navigation run off this. A name ending in `.mp4` is a video. + * + * `created_date` selects a date-based virtual board; without it the usual board/category + * filters apply. Both cases go through this one endpoint. */ - getGalleryItemNames: build.query({ + listGalleryItemNames: build.query({ query: (queryArgs) => ({ - url: buildGalleryUrl('items/names', queryArgs), + url: buildGalleryUrl('item_names', queryArgs), method: 'GET', }), - providesTags: (result, error, queryArgs) => [ - 'GalleryItemNameList', - 'FetchOnReconnect', - { type: 'GalleryItemNameList', id: stableHash(queryArgs) }, - ], + providesTags: (result, error, queryArgs) => { + const tags: ApiTagDescription[] = [ + 'GalleryItemNameList', + 'FetchOnReconnect', + { type: 'GalleryItemNameList', id: stableHash(queryArgs) }, + ]; + if (queryArgs.created_date) { + // Image and video mutations both have to refetch a virtual date's contents, so a + // date-scoped request also carries each kind's name-list tag. + tags.push({ type: 'ImageNameList', id: `virtual_${queryArgs.created_date}` }); + tags.push({ type: 'VideoNameList', id: `virtual_${queryArgs.created_date}` }); + } + return tags; + }, }), }), }); -// useGetGalleryItemNamesQuery is consumed by use-gallery-image-names.ts. -export const { useGetGalleryItemNamesQuery } = galleryApi; +// useListGalleryItemNamesQuery is consumed by use-gallery-image-names.ts. +export const { useListGalleryItemNamesQuery } = galleryApi; /** @knipignore Lands with the paged gallery view / future bulk-DTO consumers; not used today. */ export const { useListGalleryItemsQuery } = galleryApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/virtual_boards.ts b/invokeai/frontend/web/src/services/api/endpoints/virtual_boards.ts index 4a8e9ef9ffe..9cc1ba3329f 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/virtual_boards.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/virtual_boards.ts @@ -1,6 +1,3 @@ -import queryString from 'query-string'; -import type { GetGalleryItemNamesResult, ImageCategory } from 'services/api/types'; - import type { ApiTagDescription } from '..'; import { api, buildV1Url } from '..'; @@ -17,7 +14,9 @@ export type VirtualSubBoard = { const buildVirtualBoardsUrl = (path: string = '') => buildV1Url(`virtual_boards/${path}`); -export const virtualBoardsApi = api.injectEndpoints({ +// Not exported: with virtual-date name lists served by `listGalleryItemNames`, nothing outside +// this module needs the api object itself — only the hook below. +const virtualBoardsApi = api.injectEndpoints({ endpoints: (build) => ({ listVirtualBoardsByDate: build.query({ query: () => ({ @@ -25,36 +24,9 @@ export const virtualBoardsApi = api.injectEndpoints({ }), providesTags: (): ApiTagDescription[] => ['VirtualBoards', 'FetchOnReconnect'], }), - - /** - * Polymorphic (image + video) refs for a virtual date board. Same result shape as the - * gallery's `getGalleryItemNames`, so the gallery grid can consume either transparently. - */ - getVirtualBoardItemNamesByDate: build.query< - GetGalleryItemNamesResult, - { - date: string; - starred_first?: boolean; - order_dir?: 'ASC' | 'DESC'; - categories?: ImageCategory[]; - search_term?: string; - } - >({ - query: ({ date, ...params }) => ({ - url: buildVirtualBoardsUrl( - `by_date/${date}/item_names?${queryString.stringify(params, { arrayFormat: 'none', skipNull: true, skipEmptyString: true })}` - ), - }), - // Both image and video mutations must refetch a virtual date's contents, so this - // provides the name-list tag of each kind plus the polymorphic one. - providesTags: (_result, _error, arg): ApiTagDescription[] => [ - { type: 'ImageNameList', id: `virtual_${arg.date}` }, - { type: 'VideoNameList', id: `virtual_${arg.date}` }, - 'GalleryItemNameList', - 'FetchOnReconnect', - ], - }), }), }); -export const { useListVirtualBoardsByDateQuery, useGetVirtualBoardItemNamesByDateQuery } = virtualBoardsApi; +// Virtual-date name lists are served by `listGalleryItemNames` with a `created_date` filter; +// the deprecated `by_date/{date}/item_names` route is no longer called from the UI. +export const { useListVirtualBoardsByDateQuery } = virtualBoardsApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 001aa2233cd..7e12e5ab83f 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1422,7 +1422,11 @@ export type paths = { }; /** * Get Image Names - * @description Gets ordered list of image names with metadata for optimistic updates + * @deprecated + * @description Gets ordered list of image names with metadata for optimistic updates. + * + * Deprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved + * in one ordered list. This image-only endpoint predates the polymorphic gallery. */ get: operations["get_image_names"]; put?: never; @@ -1663,7 +1667,11 @@ export type paths = { }; /** * Get Video Names + * @deprecated * @description Gets ordered list of video names with metadata for optimistic updates. + * + * Deprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved + * in one ordered list. This video-only endpoint predates the polymorphic gallery. */ get: operations["get_video_names"]; put?: never; @@ -1746,6 +1754,29 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/gallery/item_names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Gallery Item Names + * @description Returns the ordered flat list of item names — used to drive virtualized gallery selection. + * + * Names are polymorphic: image and video names are interleaved by `created_at`. A name ending + * in `.mp4` is a video. + */ + get: operations["list_gallery_item_names"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/gallery/items/names": { parameters: { query?: never; @@ -1755,7 +1786,12 @@ export type paths = { }; /** * Get Gallery Item Names + * @deprecated * @description Returns an ordered (kind, name) list — used to drive virtualized gallery selection. + * + * Deprecated: use `GET /v1/gallery/item_names`, which returns the same order as a flat name + * list. The `kind` discriminator here costs a model per row — ~800ms on a 200k-item library — + * for a value callers already derive from the file extension. */ get: operations["get_gallery_item_names"]; put?: never; @@ -1931,8 +1967,11 @@ export type paths = { }; /** * List Virtual Board Image Names By Date - * @description Gets ordered image names for a specific date. Image-only; kept for API compatibility — - * the UI uses the polymorphic `/by_date/{date}/item_names` endpoint. + * @deprecated + * @description Gets ordered image names for a specific date. Image-only. + * + * Deprecated: use `GET /v1/gallery/item_names?created_date=`, which covers images and + * videos in one ordered list. */ get: operations["list_virtual_board_image_names_by_date"]; put?: never; @@ -1952,7 +1991,11 @@ export type paths = { }; /** * List Virtual Board Item Names By Date + * @deprecated * @description Gets ordered polymorphic (image + video) item refs for a specific date. + * + * Deprecated: use `GET /v1/gallery/item_names?created_date=`, which returns the same + * order as a flat name list instead of one model per item. */ get: operations["list_virtual_board_item_names_by_date"]; put?: never; @@ -2360,6 +2403,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/queue/{queue_id}/item_summaries_by_ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Queue Item Summaries By Ids + * @description Gets lightweight queue item summaries for specified IDs in requested order. + */ + post: operations["get_queue_item_summaries_by_ids"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/queue/{queue_id}/processor/resume": { parameters: { query?: never; @@ -4892,6 +4955,14 @@ export type components = { */ image_names: string[]; }; + /** Body_get_queue_item_summaries_by_ids */ + Body_get_queue_item_summaries_by_ids: { + /** + * Item Ids + * @description Object containing list of queue item ids to fetch summaries for + */ + item_ids: number[]; + }; /** Body_get_queue_items_by_item_ids */ Body_get_queue_items_by_item_ids: { /** @@ -4904,7 +4975,6 @@ export type components = { Body_import_style_presets: { /** * File - * Format: binary * @description The file to import */ file: Blob; @@ -4962,7 +5032,6 @@ export type components = { Body_set_workflow_thumbnail: { /** * Image - * Format: binary * @description The image file to upload */ image: Blob; @@ -4985,10 +5054,7 @@ export type components = { }; /** Body_update_model_image */ Body_update_model_image: { - /** - * Image - * Format: binary - */ + /** Image */ image: Blob; }; /** Body_update_style_preset */ @@ -5019,10 +5085,7 @@ export type components = { }; /** Body_upload_image */ Body_upload_image: { - /** - * File - * Format: binary - */ + /** File */ file: Blob; /** * Resize To @@ -5038,10 +5101,7 @@ export type components = { }; /** Body_upload_video */ Body_upload_video: { - /** - * File - * Format: binary - */ + /** File */ file: Blob; /** * Metadata @@ -13627,9 +13687,38 @@ export type components = { * @enum {string} */ GalleryItemKind: "image" | "video"; + /** + * GalleryItemNames + * @description Ordered flat list of gallery item names plus counts for optimistic UI. + * + * Names are polymorphic — images and videos are interleaved by `created_at`. The kind of a + * given name is its file extension (`.mp4` is a video), which is how every consumer already + * discriminates. Mirrors the shape of the image-only `ImageNamesResult`. + */ + GalleryItemNames: { + /** + * Item Names + * @description Ordered list of image and video names. + */ + item_names: string[]; + /** + * Starred Count + * @description Number of starred items (when starred_first=True). + */ + starred_count: number; + /** + * Total Count + * @description Total number of items matching the query. + */ + total_count: number; + }; /** * GalleryItemNamesResult * @description Ordered list of gallery item references plus counts for optimistic UI. + * + * Deprecated in favour of :class:`GalleryItemNames`. Wrapping every name in an object to + * carry a `kind` discriminator costs ~800ms of model construction on a 200k-item library, + * for a field callers derive from the filename extension anyway. */ GalleryItemNamesResult: { /** @@ -18560,6 +18649,7 @@ export type components = { * external_seedream_base_url: Base URL override for Seedream image generation. * base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root. * forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set. + * gzip_compresslevel: GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses. */ InvokeAIAppConfig: { /** @@ -18634,6 +18724,12 @@ export type components = { * @default 127.0.0.1 */ forwarded_allow_ips?: string; + /** + * Gzip Compresslevel + * @description GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses. + * @default 9 + */ + gzip_compresslevel?: number; /** * Log Tokenization * @description Enable logging of parsed prompt tokens. @@ -33276,6 +33372,78 @@ export type components = { /** @description The workflow associated with this queue item */ workflow?: components["schemas"]["WorkflowWithoutID"] | null; }; + /** + * SessionQueueItemSummary + * @description Queue item fields needed to render the queue list. + */ + SessionQueueItemSummary: { + /** + * Item Id + * @description The identifier of the session queue item + */ + item_id: number; + /** + * Created At + * @description When this queue item was created + */ + created_at: string; + /** + * Status + * @description The status of this queue item + * @enum {string} + */ + status: "pending" | "in_progress" | "waiting" | "completed" | "failed" | "canceled"; + /** + * Device + * @description The device that processed this queue item, e.g. 'cuda:1' + */ + device?: string | null; + /** + * Started At + * @description When this queue item was started + */ + started_at: string | null; + /** + * Completed At + * @description When this queue item was completed + */ + completed_at: string | null; + /** + * Origin + * @description The origin of this queue item + */ + origin: string | null; + /** + * Destination + * @description The destination of this queue item + */ + destination: string | null; + /** + * Batch Id + * @description The ID of the batch associated with this queue item + */ + batch_id: string; + /** + * User Id + * @description The ID of the user who created this queue item + */ + user_id: string; + /** + * User Display Name + * @description The display name of the user who created this queue item + */ + user_display_name: string | null; + /** + * User Email + * @description The email of the user who created this queue item + */ + user_email: string | null; + /** + * Field Values + * @description The batch field values used for this queue item + */ + field_values: components["schemas"]["NodeFieldValue"][] | null; + }; /** SessionQueueStatus */ SessionQueueStatus: { /** @@ -37433,6 +37601,10 @@ export type components = { msg: string; /** Error Type */ type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; }; /** VideoBoardArg */ VideoBoardArg: { @@ -44191,6 +44363,52 @@ export interface operations { }; }; }; + list_gallery_item_names: { + parameters: { + query?: { + /** @description The origin of items to list. */ + origin?: components["schemas"]["ResourceOrigin"] | null; + /** @description The categories to include. Shared between images and videos. */ + categories?: components["schemas"]["ImageCategory"][] | null; + /** @description Whether to list intermediate items. */ + is_intermediate?: boolean | null; + /** @description The board id to filter by. Use 'none' to find items without a board. */ + board_id?: string | null; + /** @description Restrict to items created on this ISO date, e.g. '2026-03-18'. Used by date-based virtual boards. */ + created_date?: string | null; + /** @description The order of sort */ + order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description Whether to sort by starred items first */ + starred_first?: boolean; + /** @description The term to search for */ + search_term?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GalleryItemNames"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_gallery_item_names: { parameters: { query?: { @@ -45403,6 +45621,42 @@ export interface operations { }; }; }; + get_queue_item_summaries_by_ids: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The queue id to perform this operation on */ + queue_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_get_queue_item_summaries_by_ids"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionQueueItemSummary"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; resume: { parameters: { query?: never; diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 98f1a6b71ee..d6a3a5d9de2 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -726,6 +726,6 @@ export type OffsetPaginatedResults_GalleryItem_ = S['OffsetPaginatedResults_Gall export type ListGalleryItemsArgs = NonNullable; export type ListGalleryItemsResponse = paths['/api/v1/gallery/items/']['get']['responses']['200']['content']['application/json']; -export type GetGalleryItemNamesArgs = NonNullable; -export type GetGalleryItemNamesResult = - paths['/api/v1/gallery/items/names']['get']['responses']['200']['content']['application/json']; +export type ListGalleryItemNamesArgs = NonNullable; +export type ListGalleryItemNamesResult = + paths['/api/v1/gallery/item_names']['get']['responses']['200']['content']['application/json']; diff --git a/pyproject.toml b/pyproject.toml index 5890ad3b37d..37bcbc99599 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,10 +59,16 @@ dependencies = [ # Core application dependencies, pinned for reproducible builds. "fastapi-events", - # FastAPI 0.119.0 breaks OpenAPI schema generation for the AnyInvocation class. It's not clear if this is an Invoke, - # FastAPI or pydantic bug. Probably Invoke's, because we are doing something unusual with AnyInvocation. 0.118.3 is - # the last known-good version. - "fastapi== 0.118.3", + # The old pin sat at 0.118.3 because 0.119.0 crashed generating our OpenAPI schema. That was a FastAPI bug, not + # ours (`KeyError: '$ref'` in fastapi/_compat/v2.py, which assumed every field mapping carries a `$ref`), and it + # is fixed as of 0.124.0 — no change to AnyInvocation was needed. + # + # Two later changes did need adapting to, both handled: 0.130 emits `contentMediaType` instead of + # `format: binary` for file uploads (see the Blob mapping in frontend/web/scripts/typegen.js), and 0.141 keeps + # included routers as a single node in `app.routes` rather than copying their routes into it (see + # `_iter_route_contexts` in tests/app/routers/test_model_manager_authorization.py). Keep the minor-version bound: + # both of those were silent breakages that only surfaced because something happened to assert on them. + "fastapi>=0.141.1,<0.142", "huggingface-hub", "pydantic-settings", "pydantic", diff --git a/tests/app/api/test_gzip_content_types.py b/tests/app/api/test_gzip_content_types.py new file mode 100644 index 00000000000..76f43d587aa --- /dev/null +++ b/tests/app/api/test_gzip_content_types.py @@ -0,0 +1,179 @@ +"""GZip must skip response types that are already compressed. + +Starlette's GZipMiddleware compresses everything except `text/event-stream`. The gallery +serves PNG, WebP and MP4 bytes, which are already deflate-compressed: gzipping a 3 MB PNG +costs ~52ms of event-loop time and returns a *larger* body. With auto-switch on, the UI +fetches the full image after every generated image, so that cost lands repeatedly during a +batch. + +These tests pin which content types are compressed, using a standalone app so they exercise +the middleware rather than the whole API surface. +""" + +import pytest +from fastapi import FastAPI, Response +from fastapi.testclient import TestClient + +from invokeai.app.api_app import ContentTypeAwareGZipMiddleware, configure_gzip + +# Comfortably above the middleware's minimum_size, and large enough that a missing exclusion +# would be obvious rather than marginal. +BODY = b"x" * 50_000 + + +def _build_app(compresslevel: int) -> TestClient: + app = FastAPI() + + @app.get("/payload") + def payload(content_type: str) -> Response: + return Response(content=BODY, media_type=content_type) + + @app.get("/tiny") + def tiny() -> Response: + return Response(content=b"small", media_type="application/json") + + configure_gzip(app, compresslevel) + return TestClient(app) + + +@pytest.fixture +def client() -> TestClient: + return _build_app(compresslevel=1) + + +@pytest.mark.parametrize( + "content_type", + [ + "application/json", + "text/html; charset=utf-8", + "text/css", + "application/javascript", + "image/svg+xml", + ], +) +def test_compressible_types_are_compressed(client: TestClient, content_type: str): + r = client.get("/payload", params={"content_type": content_type}, headers={"Accept-Encoding": "gzip"}) + + assert r.status_code == 200 + assert r.headers["content-encoding"] == "gzip" + # httpx decodes transparently, so this also proves the compressed body round-trips. + assert r.content == BODY + assert int(r.headers["content-length"]) < len(BODY) + + +@pytest.mark.parametrize( + "content_type", + [ + "image/png", + "image/webp", + "image/jpeg", + "video/mp4", + "application/zip", + "application/octet-stream", + # `text/` prefix matches, but compressing an event stream withholds events until the + # compressor flushes. + "text/event-stream", + ], +) +def test_already_compressed_types_are_passed_through(client: TestClient, content_type: str): + r = client.get("/payload", params={"content_type": content_type}, headers={"Accept-Encoding": "gzip"}) + + assert r.status_code == 200 + assert "content-encoding" not in r.headers, f"{content_type} should not be gzipped" + assert r.content == BODY + + +def test_small_responses_are_left_alone(client: TestClient): + r = client.get("/tiny", headers={"Accept-Encoding": "gzip"}) + + assert r.status_code == 200 + assert "content-encoding" not in r.headers + + +def test_the_real_app_uses_the_content_type_aware_middleware(): + """Without this, the tests above would keep passing while the app served gzipped PNGs.""" + from starlette.middleware.gzip import GZipMiddleware + + from invokeai.app.api_app import app + + installed = [m.cls for m in app.user_middleware] + assert ContentTypeAwareGZipMiddleware in installed + assert GZipMiddleware not in installed + + +@pytest.mark.parametrize("content_type", ["application/json", "text/html; charset=utf-8", "image/svg+xml"]) +def test_level_zero_disables_compression_entirely(content_type: str): + """`gzip_compresslevel: 0` is how a deployment behind a compressing proxy opts out.""" + disabled = _build_app(compresslevel=0) + + r = disabled.get("/payload", params={"content_type": content_type}, headers={"Accept-Encoding": "gzip"}) + + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == BODY + + +def test_level_zero_leaves_the_middleware_out(): + """Installing it at level 0 would still buffer every response through the responder.""" + app = FastAPI() + configure_gzip(app, 0) + + assert [m.cls for m in app.user_middleware] == [] + + +def test_the_configured_level_reaches_the_compressor(): + """A level that is accepted but ignored would silently keep the old 90ms-per-response cost.""" + # Repetitive but varied, so the higher level's larger window actually finds more matches — + # `b"x" * n` would compress identically at every level and prove nothing. + body = "".join(f'"{i:08x}-image-{i % 7}.png",' for i in range(20_000)).encode() + + sizes: dict[int, int] = {} + for level in (1, 9): + app = FastAPI() + + @app.get("/names") + def names() -> Response: + return Response(content=body, media_type="application/json") + + configure_gzip(app, level) + r = TestClient(app).get("/names", headers={"Accept-Encoding": "gzip"}) + + assert r.headers["content-encoding"] == "gzip" + assert r.content == body + sizes[level] = int(r.headers["content-length"]) + + assert sizes[9] < sizes[1], "compresslevel is not being passed through to the compressor" + + +def test_the_real_app_uses_the_configured_level(): + from invokeai.app.api_app import app, app_config + + installed = [m for m in app.user_middleware if m.cls is ContentTypeAwareGZipMiddleware] + assert len(installed) == 1 + assert installed[0].kwargs["compresslevel"] == app_config.gzip_compresslevel + + +def test_the_default_level_is_unchanged(): + """Adding the setting must not change what existing installs do — the default is still 9.""" + from invokeai.app.services.config.config_default import InvokeAIAppConfig + + assert InvokeAIAppConfig().gzip_compresslevel == 9 + + +@pytest.mark.parametrize("level", [-1, 10]) +def test_out_of_range_levels_are_rejected(level: int): + """zlib would raise deep inside the responder, mid-response, rather than at startup.""" + from pydantic import ValidationError + + from invokeai.app.services.config.config_default import InvokeAIAppConfig + + with pytest.raises(ValidationError): + InvokeAIAppConfig(gzip_compresslevel=level) + + +def test_clients_without_gzip_support_get_plain_bodies(client: TestClient): + r = client.get("/payload", params={"content_type": "application/json"}, headers={"Accept-Encoding": "identity"}) + + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == BODY diff --git a/tests/app/routers/conftest.py b/tests/app/routers/conftest.py index e5ca28e16f1..ab4f9e65cda 100644 --- a/tests/app/routers/conftest.py +++ b/tests/app/routers/conftest.py @@ -55,6 +55,7 @@ def __init__(self, invoker: Invoker) -> None: "invokeai.app.api.routers.model_relationships", "invokeai.app.api.routers.utilities", "invokeai.app.api.routers.virtual_boards", + "invokeai.app.api.routers.gallery", "invokeai.app.api.routers.images", "invokeai.app.api.routers.workflows", "invokeai.app.api.routers._access", diff --git a/tests/app/routers/test_custom_nodes.py b/tests/app/routers/test_custom_nodes.py index 3fa86c08a59..5a20397ffc9 100644 --- a/tests/app/routers/test_custom_nodes.py +++ b/tests/app/routers/test_custom_nodes.py @@ -1,6 +1,5 @@ """Tests for the custom nodes router.""" -import asyncio import json import sys from pathlib import Path @@ -135,7 +134,9 @@ def test_rejects_invalid_pack_names_before_filesystem_side_effects(self) -> None patch("invokeai.app.api.routers.custom_nodes.shutil") as mock_shutil, patch("invokeai.app.api.routers.custom_nodes._remove_workflows_by_ids") as mock_remove_workflows, ): - response = asyncio.run(uninstall_custom_node_pack(MagicMock(), pack_name)) + # The route is `def`, not `async def`, so that its synchronous filesystem work + # runs in the threadpool instead of on the event loop. + response = uninstall_custom_node_pack(MagicMock(), pack_name) assert response.name == pack_name assert response.success is False diff --git a/tests/app/routers/test_event_loop_blocking.py b/tests/app/routers/test_event_loop_blocking.py new file mode 100644 index 00000000000..a50fde4a5e0 --- /dev/null +++ b/tests/app/routers/test_event_loop_blocking.py @@ -0,0 +1,137 @@ +"""Guards that slow gallery queries do not stall the whole server. + +The gallery list/name routes run synchronous SQLite work. If such a route is declared +`async def`, that work executes *on the event loop*, so for its whole duration the process +serves nothing else - no other HTTP request, no socket.io progress event. On a large +library a single search can take minutes, which users experience as the backend being +dead rather than as a slow search. + +Declaring these routes `def` hands them to Starlette's threadpool instead, leaving the +loop free. These tests pin that property down: the slowness is simulated with a +synchronous sleep in the service layer, so they assert the *dispatch mechanism* rather +than any particular query's speed, and stay fast and deterministic in CI. +""" + +import asyncio +import time +from unittest.mock import MagicMock + +import pytest +from httpx import ASGITransport, AsyncClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.app.services.gallery.gallery_common import GalleryItem, GalleryItemNames, GalleryItemNamesResult +from invokeai.app.services.image_records.image_records_common import ImageNamesResult +from invokeai.app.services.session_queue.session_queue_common import SessionQueueItemSummary +from invokeai.app.services.shared.pagination import OffsetPaginatedResults + +# Long enough that a blocked event loop is unmistakable, short enough to keep the suite fast. +BLOCKING_SECONDS = 1.0 + +# A trivial route with no auth dependency and no database access. If the loop is free, this +# answers in single-digit milliseconds no matter what else the server is doing. +PROBE_ROUTE = "/api/v1/app/version" + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.fixture +def blocking_invoker(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Point every router at services whose gallery/image reads block for BLOCKING_SECONDS. + + Patching the attribute on the class itself covers all routers at once - they share the + single `ApiDependencies` object rather than importing their own copy. + """ + invoker = MagicMock() + # A bare MagicMock attribute is truthy, which would put the auth dependencies into + # multiuser mode and answer every request with 401 before the route is ever reached. + invoker.services.configuration.multiuser = False + + def slow_list_item_names(**_: object) -> GalleryItemNamesResult: + time.sleep(BLOCKING_SECONDS) + return GalleryItemNamesResult(items=[], starred_count=0, total_count=0) + + def slow_get_image_names(**_: object) -> ImageNamesResult: + time.sleep(BLOCKING_SECONDS) + return ImageNamesResult(image_names=[], starred_count=0, total_count=0) + + def slow_list_items(**_: object) -> OffsetPaginatedResults[GalleryItem]: + time.sleep(BLOCKING_SECONDS) + return OffsetPaginatedResults[GalleryItem](limit=10, offset=0, total=0, items=[]) + + def slow_get_item_names(**_: object) -> GalleryItemNames: + time.sleep(BLOCKING_SECONDS) + return GalleryItemNames(item_names=[], starred_count=0, total_count=0) + + def slow_queue_item_summaries(**_: object) -> list[SessionQueueItemSummary]: + time.sleep(BLOCKING_SECONDS) + return [] + + invoker.services.gallery.list_item_names.side_effect = slow_list_item_names + invoker.services.gallery.get_item_names.side_effect = slow_get_item_names + invoker.services.gallery.list_items.side_effect = slow_list_items + invoker.services.images.get_image_names.side_effect = slow_get_image_names + invoker.services.session_queue.get_queue_item_summaries_by_ids.side_effect = slow_queue_item_summaries + + monkeypatch.setattr(ApiDependencies, "invoker", invoker, raising=False) + return invoker + + +async def _probe_latency_while_busy( + client: AsyncClient, slow_route: str, params: dict, json_body: dict | None = None +) -> tuple[float, asyncio.Task]: + """Start `slow_route`, then time a probe request issued while it is still running. + + The clock starts before yielding to the slow request, so a blocked loop shows up as + probe latency even though the probe itself never got a chance to be dispatched. + """ + started = time.perf_counter() + if json_body is None: + slow_request = asyncio.create_task(client.get(slow_route, params=params)) + else: + slow_request = asyncio.create_task(client.post(slow_route, params=params, json=json_body)) + # Hand control to the slow request so it reaches its route handler before we probe. + for _ in range(10): + await asyncio.sleep(0) + + response = await client.get(PROBE_ROUTE) + elapsed = time.perf_counter() - started + + assert response.status_code == 200 + return elapsed, slow_request + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "slow_route,params,json_body", + [ + ("/api/v1/gallery/items/names", {}, None), + ("/api/v1/gallery/items/names", {"search_term": "anything"}, None), + ("/api/v1/gallery/item_names", {}, None), + ("/api/v1/gallery/items/", {}, None), + ("/api/v1/images/names", {}, None), + ("/api/v1/queue/default/item_summaries_by_ids", {}, {"item_ids": [1, 2, 3]}), + ], +) +async def test_slow_gallery_read_leaves_the_event_loop_free( + blocking_invoker: MagicMock, slow_route: str, params: dict, json_body: dict | None +) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + elapsed, slow_request = await _probe_latency_while_busy(client, slow_route, params, json_body) + + assert elapsed < BLOCKING_SECONDS / 2, ( + f"{PROBE_ROUTE} took {elapsed:.2f}s while {slow_route} was running. The slow route is " + f"executing its blocking database work on the event loop, so the server answers " + f"nothing else until it finishes. Declare the route `def` instead of `async def`." + ) + assert not slow_request.done(), ( + "The slow request finished before the probe was even dispatched, so nothing was " + "measured concurrently - the event loop was blocked for its full duration." + ) + + await slow_request diff --git a/tests/app/routers/test_gallery_item_names.py b/tests/app/routers/test_gallery_item_names.py new file mode 100644 index 00000000000..0dec1103b26 --- /dev/null +++ b/tests/app/routers/test_gallery_item_names.py @@ -0,0 +1,162 @@ +"""Router-level tests for GET /api/v1/gallery/item_names. + +This endpoint replaces two deprecated ones — `/gallery/items/names` and +`/virtual_boards/by_date/{date}/item_names` — with a flat name list. The deprecated shape +wrapped every name in an object carrying a `kind` discriminator, which cost one model per row +(~800ms on a 200k-item library) for a value callers derive from the file extension. + +What matters here is that the replacement is faithful: same order, same filtering, same +per-user isolation, and videos still present. Where possible that is asserted *against* the +deprecated endpoint rather than against hardcoded expectations, so the two cannot drift while +both are still served. +""" + +from typing import Any + +from fastapi import status +from fastapi.testclient import TestClient + +from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin +from invokeai.app.services.invoker import Invoker + +ITEM_NAMES_URL = "/api/v1/gallery/item_names" +DEPRECATED_URL = "/api/v1/gallery/items/names" + + +def _save_image(mock_invoker: Invoker, image_name: str, user_id: str) -> None: + mock_invoker.services.image_records.save( + image_name=image_name, + image_origin=ResourceOrigin.INTERNAL, + image_category=ImageCategory.GENERAL, + width=10, + height=10, + has_workflow=False, + user_id=user_id, + ) + + +def _save_video(mock_invoker: Invoker, video_name: str, user_id: str) -> None: + mock_invoker.services.video_records.save( + video_name=video_name, + video_origin=ResourceOrigin.INTERNAL, + video_category=ImageCategory.GENERAL, + width=10, + height=10, + duration=1.0, + fps=8.0, + has_workflow=False, + is_intermediate=False, + user_id=user_id, + ) + + +def test_requires_auth_in_multiuser_mode(enable_multiuser: Any, client: TestClient): + r = client.get(ITEM_NAMES_URL) + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_returns_flat_names_including_videos(client: TestClient, user1_token: str, mock_invoker: Invoker): + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + + _save_image(mock_invoker, "img-a.png", user1.user_id) + _save_video(mock_invoker, "vid-a.mp4", user1.user_id) + + r = client.get(ITEM_NAMES_URL, headers={"Authorization": f"Bearer {user1_token}"}) + assert r.status_code == status.HTTP_200_OK + body = r.json() + + assert sorted(body["item_names"]) == ["img-a.png", "vid-a.mp4"] + assert body["total_count"] == 2 + # The names are plain strings — no per-item object wrapper. + assert all(isinstance(name, str) for name in body["item_names"]) + + +def test_matches_the_deprecated_endpoint_order(client: TestClient, user1_token: str, mock_invoker: Invoker): + """The replacement must not reorder anything, or virtualized selection silently breaks.""" + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + + for i in range(5): + _save_image(mock_invoker, f"img-{i}.png", user1.user_id) + _save_video(mock_invoker, "vid-mid.mp4", user1.user_id) + + headers = {"Authorization": f"Bearer {user1_token}"} + new = client.get(ITEM_NAMES_URL, headers=headers) + old = client.get(DEPRECATED_URL, headers=headers) + assert new.status_code == status.HTTP_200_OK + assert old.status_code == status.HTTP_200_OK + + assert new.json()["item_names"] == [item["name"] for item in old.json()["items"]] + assert new.json()["total_count"] == old.json()["total_count"] + assert new.json()["starred_count"] == old.json()["starred_count"] + + +def test_created_date_matches_the_deprecated_virtual_board_endpoint( + client: TestClient, user1_token: str, mock_invoker: Invoker +): + """`created_date` is the replacement for the by-date virtual-board route.""" + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + + _save_image(mock_invoker, "dated-img.png", user1.user_id) + _save_video(mock_invoker, "dated-vid.mp4", user1.user_id) + + headers = {"Authorization": f"Bearer {user1_token}"} + # Read the date the records actually landed on rather than assuming today's date, so the + # test cannot fail when it runs across a midnight boundary. + dates = client.get("/api/v1/virtual_boards/by_date", headers=headers).json() + assert len(dates) == 1 + date = dates[0]["date"] + + new = client.get(ITEM_NAMES_URL, params={"created_date": date, "is_intermediate": "false"}, headers=headers) + old = client.get(f"/api/v1/virtual_boards/by_date/{date}/item_names", headers=headers) + assert new.status_code == status.HTTP_200_OK + assert old.status_code == status.HTTP_200_OK + + assert new.json()["item_names"] == [item["name"] for item in old.json()["items"]] + assert "dated-vid.mp4" in new.json()["item_names"] + + +def test_created_date_excludes_other_dates(client: TestClient, user1_token: str, mock_invoker: Invoker): + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + + _save_image(mock_invoker, "dated-img.png", user1.user_id) + + r = client.get( + ITEM_NAMES_URL, + params={"created_date": "1999-01-01", "is_intermediate": "false"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert r.status_code == status.HTTP_200_OK + assert r.json()["item_names"] == [] + assert r.json()["total_count"] == 0 + + +def test_non_admin_sees_only_own_items(client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker): + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user1 is not None and user2 is not None + + _save_image(mock_invoker, "u1-only.png", user1.user_id) + _save_image(mock_invoker, "u2-only.png", user2.user_id) + + r = client.get(ITEM_NAMES_URL, headers={"Authorization": f"Bearer {user1_token}"}) + assert r.status_code == status.HTTP_200_OK + assert r.json()["item_names"] == ["u1-only.png"] + + +def test_marked_deprecated_in_the_openapi_schema(client: TestClient): + """External integrations rely on the old routes, so they stay served — but signposted.""" + schema = client.get("/openapi.json").json() + + assert schema["paths"][ITEM_NAMES_URL]["get"].get("deprecated") is not True + for path in ( + DEPRECATED_URL, + "/api/v1/images/names", + "/api/v1/videos/names", + "/api/v1/virtual_boards/by_date/{date}/image_names", + "/api/v1/virtual_boards/by_date/{date}/item_names", + ): + assert schema["paths"][path]["get"]["deprecated"] is True, f"{path} should be marked deprecated" diff --git a/tests/app/routers/test_model_manager_authorization.py b/tests/app/routers/test_model_manager_authorization.py index 3cb937f511a..fda438261df 100644 --- a/tests/app/routers/test_model_manager_authorization.py +++ b/tests/app/routers/test_model_manager_authorization.py @@ -175,13 +175,40 @@ def test_create_image_upload_entry_requires_auth_before_the_501_stub( } -def _routes_without_auth() -> set[tuple[str, str]]: - """Every (method, path) in the app whose dependency tree contains no auth dependency.""" +# A floor, not an exact count: routes come and go, but a *collapse* means the traversal below stopped +# seeing the app rather than that the app shrank. Without this the guard fails open — see +# `_iter_api_route_contexts`. +MIN_EXPECTED_API_ROUTES = 150 + + +def _iter_api_route_contexts() -> list[Any]: + """Every API route in the app, with its full external path. + + Walking `app.routes` directly is not enough: since FastAPI 0.141 an included router stays a single + node in `app.routes` instead of having its routes copied into it, so `isinstance(route, APIRoute)` + matched 2 of ~197 routes and this guard passed while inspecting almost nothing. `iter_route_contexts` + is the traversal FastAPI's own OpenAPI generation uses, so by construction it yields exactly the + routes the app exposes. The context's `path` is the full external path — `route.path` is only the + portion below the router prefix. + """ + from fastapi import routing from fastapi.routing import APIRoute - from invokeai.app.api import auth_dependencies from invokeai.app.api_app import app + contexts = [ctx for ctx in routing.iter_route_contexts(app.routes) if isinstance(ctx.route, APIRoute)] + assert len(contexts) >= MIN_EXPECTED_API_ROUTES, ( + f"Only {len(contexts)} API routes discovered, expected at least {MIN_EXPECTED_API_ROUTES}. FastAPI has " + "likely changed how routes are stored again, which makes the auth guard below inspect almost nothing " + "and pass. Fix the traversal - do not lower this floor." + ) + return contexts + + +def _routes_without_auth() -> set[tuple[str, str]]: + """Every (method, path) in the app whose dependency tree contains no auth dependency.""" + from invokeai.app.api import auth_dependencies + auth_functions = { auth_dependencies.get_current_user, auth_dependencies.get_current_user_or_default, @@ -194,10 +221,10 @@ def has_auth(dependant: Any) -> bool: return any(has_auth(sub) for sub in dependant.dependencies) return { - (method, route.path) - for route in app.routes - if isinstance(route, APIRoute) and not has_auth(route.dependant) - for method in route.methods + (method, ctx.path) + for ctx in _iter_api_route_contexts() + if not has_auth(ctx.route.dependant) + for method in ctx.route.methods } diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index be5d2a61beb..56fb469e4ca 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -1099,6 +1099,10 @@ def test_get_queue_item_ids_requires_auth(self, enable_multiuser: Any, client: T r = client.get("/api/v1/queue/default/item_ids") assert r.status_code == status.HTTP_401_UNAUTHORIZED + def test_get_queue_item_summaries_by_ids_requires_auth(self, enable_multiuser: Any, client: TestClient): + r = client.post("/api/v1/queue/default/item_summaries_by_ids", json={"item_ids": [1]}) + assert r.status_code == status.HTTP_401_UNAUTHORIZED + def test_get_current_queue_item_requires_auth(self, enable_multiuser: Any, client: TestClient): r = client.get("/api/v1/queue/default/current") assert r.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/app/routers/test_no_blocking_async_routes.py b/tests/app/routers/test_no_blocking_async_routes.py new file mode 100644 index 00000000000..3a6f681084f --- /dev/null +++ b/tests/app/routers/test_no_blocking_async_routes.py @@ -0,0 +1,68 @@ +"""No route handler may be `async def` without awaiting something. + +Nearly every service in the backend is synchronous. A route declared `async def` that only +calls those services runs their blocking work *on the event loop*, so for its duration the +process serves no other request and delivers no socket.io event. On a large library that is +seconds per request, which users experience as the application freezing mid-generation. + +`tests/app/routers/test_event_loop_blocking.py` proves the effect for a handful of routes. +This test enforces the rule for all of them, because a per-route test cannot cover a route +that does not exist yet — and the failure mode is invisible until someone has a big enough +library to notice. + +See docs/contributing/blocking-work-in-api-routes for the rule itself. +""" + +import ast +import pathlib + +ROUTERS_DIR = pathlib.Path(__file__).parents[3] / "invokeai" / "app" / "api" / "routers" + +# Decorators that register a function as a route. `api_route` takes the method as a keyword, +# the rest name it directly. +ROUTE_DECORATORS = {"get", "post", "put", "patch", "delete", "head", "options", "api_route"} + +# A floor, not an exact count: routes come and go, but a collapse means this test stopped +# finding the routers rather than that the app shrank. +MIN_EXPECTED_ROUTE_HANDLERS = 150 + + +def _is_route_handler(node: ast.AsyncFunctionDef | ast.FunctionDef) -> bool: + for decorator in node.decorator_list: + target = decorator.func if isinstance(decorator, ast.Call) else decorator + if isinstance(target, ast.Attribute) and target.attr in ROUTE_DECORATORS: + return True + return False + + +def _awaits_something(node: ast.AsyncFunctionDef) -> bool: + return any(isinstance(child, (ast.Await, ast.AsyncWith, ast.AsyncFor)) for child in ast.walk(node)) + + +def test_no_route_handler_is_async_without_awaiting() -> None: + offenders: list[str] = [] + handlers = 0 + + for path in sorted(ROUTERS_DIR.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)) or not _is_route_handler(node): + continue + handlers += 1 + if isinstance(node, ast.AsyncFunctionDef) and not _awaits_something(node): + offenders.append(f"{path.name}:{node.lineno} {node.name}") + + assert handlers >= MIN_EXPECTED_ROUTE_HANDLERS, ( + f"Only {handlers} route handlers found in {ROUTERS_DIR}, expected at least " + f"{MIN_EXPECTED_ROUTE_HANDLERS}. This test has stopped seeing the routers - fix the discovery, " + "do not lower this floor." + ) + + assert not offenders, ( + "These route handlers are declared `async def` but never await anything, so their " + "synchronous work runs on the event loop and stalls the whole server while it runs:\n " + + "\n ".join(offenders) + + "\n\nDeclare them `def` - FastAPI will run them in a threadpool. If a handler genuinely " + "needs to be async, it must await something; blocking calls inside it belong in " + "`run_in_threadpool`. See docs/contributing/blocking-work-in-api-routes." + ) diff --git a/tests/app/routers/test_session_queue_sanitization.py b/tests/app/routers/test_session_queue_sanitization.py index 9fe15510589..0b05218c603 100644 --- a/tests/app/routers/test_session_queue_sanitization.py +++ b/tests/app/routers/test_session_queue_sanitization.py @@ -4,10 +4,14 @@ import pytest -from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user +from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user, sanitize_queue_item_summary_for_user from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output from invokeai.app.invocations.fields import InputField, OutputField -from invokeai.app.services.session_queue.session_queue_common import NodeFieldValue, SessionQueueItem +from invokeai.app.services.session_queue.session_queue_common import ( + NodeFieldValue, + SessionQueueItem, + SessionQueueItemSummary, +) from invokeai.app.services.shared.graph import Graph, GraphExecutionState from invokeai.app.services.shared.invocation_context import InvocationContext @@ -189,3 +193,23 @@ def test_sanitize_system_user_item_for_admin(sample_session_queue_item): assert result.field_values is not None assert len(result.field_values) == 1 assert len(result.session.graph.nodes) == 1 + + +def test_sanitize_queue_item_summary_for_different_user(sample_session_queue_item: SessionQueueItem) -> None: + summary = SessionQueueItemSummary(**sample_session_queue_item.model_dump()) + + result = sanitize_queue_item_summary_for_user(summary, current_user_id="different_user", is_admin=False) + + assert result.item_id == summary.item_id + assert result.created_at == summary.created_at + assert result.status == summary.status + assert result.started_at == summary.started_at + assert result.completed_at == summary.completed_at + assert result.device is None + assert result.origin is None + assert result.destination is None + assert result.batch_id == "redacted" + assert result.user_id == "redacted" + assert result.user_display_name is None + assert result.user_email is None + assert result.field_values is None diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py index 8282346b114..f007fdb8cf8 100644 --- a/tests/app/routers/test_videos_multiuser.py +++ b/tests/app/routers/test_videos_multiuser.py @@ -11,7 +11,6 @@ filter is covered separately in tests/app/services/video_records. """ -import asyncio import inspect from pathlib import Path from typing import Any @@ -673,12 +672,13 @@ def test_get_video_thumbnail_closes_file_before_route_returns( mock_invoker.services.videos.get_path.return_value = str(thumbnail_path) current_user = MagicMock(is_admin=True) - async def get_thumbnail_after_delete() -> bytes: - response = await get_video_thumbnail(current_user=current_user, video_name="video.mp4") - thumbnail_path.unlink() - return bytes(response.body) + # The route is `def`, not `async def`, so that its synchronous file read runs in the + # threadpool instead of on the event loop. Deleting the file straight after it returns is + # what proves the handle was closed before the response was built. + response = get_video_thumbnail(current_user=current_user, video_name="video.mp4") + thumbnail_path.unlink() - assert asyncio.run(get_thumbnail_after_delete()) == b"thumbnail-data" + assert bytes(response.body) == b"thumbnail-data" @pytest.mark.parametrize( diff --git a/tests/app/services/session_queue/test_session_queue_status_user_scoping.py b/tests/app/services/session_queue/test_session_queue_status_user_scoping.py index c9e2147b0e8..47111d84a72 100644 --- a/tests/app/services/session_queue/test_session_queue_status_user_scoping.py +++ b/tests/app/services/session_queue/test_session_queue_status_user_scoping.py @@ -143,3 +143,40 @@ def test_get_queue_item_ids_returns_all_users_ids(session_queue: SqliteSessionQu assert set(result.item_ids) == {a_item_id, b_item_id} assert result.total_count == 2 + + +def test_get_queue_item_summaries_by_ids_returns_only_requested_queue_items_in_order( + session_queue: SqliteSessionQueue, +) -> None: + first_id = _insert_queue_item(session_queue, user_id="user-a") + second_id = _insert_queue_item(session_queue, user_id="user-b") + with session_queue._db.transaction() as cursor: + cursor.execute( + """--sql + UPDATE session_queue + SET origin = ?, destination = ?, device = ?, field_values = ? + WHERE item_id = ? + """, + ( + "canvas", + "gallery", + "cuda:1", + '[{"node_path":"node","field_name":"seed","value":123}]', + second_id, + ), + ) + + summaries = session_queue.get_queue_item_summaries_by_ids( + queue_id="default", item_ids=[second_id, 999999, first_id] + ) + + assert [item.item_id for item in summaries] == [second_id, first_id] + assert summaries[0].origin == "canvas" + assert summaries[0].destination == "gallery" + assert summaries[0].device == "cuda:1" + assert summaries[0].field_values is not None + assert summaries[0].field_values[0].field_name == "seed" + assert summaries[0].field_values[0].value == 123 + assert summaries[0].user_id == "user-b" + assert summaries[0].created_at is not None + assert summaries[0].status == "pending" diff --git a/uv.lock b/uv.lock index 0051ccc39f3..a6c13cb8ad3 100644 --- a/uv.lock +++ b/uv.lock @@ -837,16 +837,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.118.3" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "pydantic", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "starlette", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "typing-extensions", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "typing-inspection", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/e0/b2c4c5fed29587f0c0c56cec9b59f2c3ca58fd40e6c96d9a788219662a35/fastapi-0.118.3.tar.gz", hash = "sha256:5bf36d9bb0cd999e1aefcad74985a6d6a1fc3a35423d497f9e1317734633411d", size = 312055, upload-time = "2025-10-10T10:40:18.15Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/04/2f9e8a965f4214883258a6f716fea324d1b81e97bce6346cfbafffe6b86c/fastapi-0.118.3-py3-none-any.whl", hash = "sha256:8b9673dc083b4b9d3d295d49ba1c0a2abbfb293d34ba210fd9b0a90d5f39981e", size = 97957, upload-time = "2025-10-10T10:40:16.118Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] @@ -1287,7 +1289,7 @@ requires-dist = [ { name = "dynamicprompts" }, { name = "einops" }, { name = "email-validator", specifier = ">=2.0.0" }, - { name = "fastapi", specifier = "==0.118.3" }, + { name = "fastapi", specifier = ">=0.141.1,<0.142" }, { name = "fastapi-events" }, { name = "gguf" }, { name = "gprof2dot", marker = "extra == 'dev'" },