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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/content/docs/configuration/invokeai-yaml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Available strategies:
| `type` | `outputs/images/general/abc123.png` | Organize images by image category. |
| `hash` | `outputs/images/ab/abc123.png` | Use the first two characters of the image UUID for filesystem performance with large collections. |

Changing this setting only affects newly-created images. Existing images remain in their current locations.
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/).

#### Logging

Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/features/gallery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ Each board has a context menu accessible via right-click (or Ctrl+click).
Deleting a board will **permanently delete all images** contained within it. Proceed with caution!
:::

### Image Storage Maintenance

Administrators can use [Image Storage Maintenance](/features/image-storage-maintenance/) to move existing image files and thumbnails into the current image subfolder strategy. This is separate from board organization and does not change image names, boards, generation metadata, or gallery records.

### Board Contents

Every board is organized into two distinct tabs:
Expand Down
32 changes: 32 additions & 0 deletions docs/src/content/docs/features/image-storage-maintenance.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: Image Storage Maintenance
description: Move existing images into the current image subfolder strategy with crash recovery.
---

InvokeAI can move existing images into the folder layout selected by [`image_subfolder_strategy`](/configuration/invokeai-yaml/#image-subfolder-strategy). This is useful after changing the image subfolder strategy from `flat` to `date`, `type`, or `hash`, or when reorganizing an older image library.

This operation changes where image files and thumbnails are stored on disk. It does not change image names, boards, generation metadata, or gallery records.

## Access

Image storage maintenance is available from the in-application Settings panel.

Administrators can start image moves, force recovery, and inspect move job details. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode cannot run or inspect image move jobs.

## Maintenance Mode

Image moves run as a maintenance operation. Before a move starts, InvokeAI checks that no queue work is active. While the move is running, InvokeAI prevents image reads, uploads, deletes, generation jobs, and gallery mutations from racing with the filesystem move.

The UI shows the move or recovery state until the job is complete or requires manual attention. Gallery images and thumbnails may be unavailable while maintenance is active.

## Crash Recovery

The move process is crash-recoverable. InvokeAI records each move job in its database before moving files, moves full-size images and thumbnails on disk, and updates `images.image_subfolder` only after the filesystem move succeeds.

If InvokeAI stops during a move, recovery resumes incomplete jobs. If recovery finds an ambiguous filesystem state, such as both the old and new full-size image files existing or neither file existing, it halts the job for manual repair instead of blindly updating the database.

Missing intermediate image files are treated as already cleaned up and do not halt the move. Missing non-intermediate image files still require operator attention.

For the `date` strategy, existing images are moved according to their original image creation timestamp stored in the database, not according to the time the maintenance job is run.

Empty source directories left behind by successful moves are removed when safe.
3 changes: 3 additions & 0 deletions invokeai/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage
from invokeai.app.services.image_moves.image_moves_default import ImageMoveService
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
from invokeai.app.services.images.images_default import ImageService
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
Expand Down Expand Up @@ -130,6 +131,7 @@ def initialize(
events = FastAPIEventService(event_handler_id, loop=loop)
bulk_download = BulkDownloadService()
image_records = SqliteImageRecordStorage(db=db)
image_moves = ImageMoveService(db=db, image_files=image_files, config=configuration, logger=logger)
images = ImageService()
invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size)
tensors = ObjectSerializerForwardCache(
Expand Down Expand Up @@ -198,6 +200,7 @@ def initialize(
configuration=configuration,
events=events,
image_files=image_files,
image_moves=image_moves,
image_records=image_records,
images=images,
invocation_cache=invocation_cache,
Expand Down
19 changes: 19 additions & 0 deletions invokeai/app/api/routers/board_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive
from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult

board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
Expand Down Expand Up @@ -65,6 +66,7 @@ async def add_image_to_board(
"""Creates a board_image"""
_assert_board_write_access(board_id, current_user)
_assert_image_direct_owner(image_name, current_user)
assert_image_move_maintenance_inactive()
try:
added_images: set[str] = set()
affected_boards: set[str] = set()
Expand Down Expand Up @@ -100,6 +102,7 @@ async def remove_image_from_board(
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
if old_board_id != "none":
_assert_board_write_access(old_board_id, current_user)
assert_image_move_maintenance_inactive()
removed_images: set[str] = set()
affected_boards: set[str] = set()
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
Expand Down Expand Up @@ -133,6 +136,13 @@ async def add_images_to_board(
) -> AddImagesToBoardResult:
"""Adds a list of images to a board"""
_assert_board_write_access(board_id, current_user)
try:
assert_image_move_maintenance_inactive()
except HTTPException:
for image_name in image_names:
_assert_image_direct_owner(image_name, current_user)
raise

try:
added_images: set[str] = set()
affected_boards: set[str] = set()
Expand Down Expand Up @@ -178,6 +188,15 @@ async def remove_images_from_board(
image_names: list[str] = Body(description="The names of the images to remove", embed=True),
) -> RemoveImagesFromBoardResult:
"""Removes a list of images from their board, if they had one"""
try:
assert_image_move_maintenance_inactive()
except HTTPException:
for image_name in image_names:
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
if old_board_id != "none":
_assert_board_write_access(old_board_id, current_user)
raise

try:
removed_images: set[str] = set()
affected_boards: set[str] = set()
Expand Down
4 changes: 4 additions & 0 deletions invokeai/app/api/routers/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.image_records.image_records_common import ImageCategory
Expand Down Expand Up @@ -118,6 +119,7 @@ async def delete_board(

try:
if include_images is True:
assert_image_move_maintenance_inactive()
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id,
categories=None,
Expand All @@ -142,6 +144,8 @@ async def delete_board(
deleted_board_images=deleted_board_images,
deleted_images=[],
)
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete board")

Expand Down
17 changes: 17 additions & 0 deletions invokeai/app/api/routers/image_move_maintenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from fastapi import HTTPException, status

from invokeai.app.api.dependencies import ApiDependencies

IMAGE_MOVE_MAINTENANCE_ACTIVE_DETAIL = "Image storage maintenance is active"


def assert_image_move_maintenance_inactive() -> None:
invoker = getattr(ApiDependencies, "invoker", None)
if invoker is None:
return
image_moves = getattr(invoker.services, "image_moves", None)
if image_moves is not None and image_moves.is_maintenance_active():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=IMAGE_MOVE_MAINTENANCE_ACTIVE_DETAIL,
)
92 changes: 92 additions & 0 deletions invokeai/app/api/routers/image_moves.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from fastapi import HTTPException, status
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field

from invokeai.app.api.auth_dependencies import AdminUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.image_moves.image_moves_default import (
ImageMoveBackgroundOperation,
ImageMoveBackgroundStatus,
ImageMoveJob,
ImageMoveJobAlreadyRunning,
ImageMoveQueueActive,
MoveJobState,
)

image_moves_router = APIRouter(prefix="/v1/image_moves", tags=["image_moves"])


class ImageMoveJobResponse(BaseModel):
id: int = Field(description="The image move job id.")
state: MoveJobState = Field(description="The image move job state.")
error_message: str | None = Field(default=None, description="The last error recorded for the job, if any.")


class ImageMoveStatusResponse(BaseModel):
is_running: bool = Field(description="Whether an image move background operation is currently running.")
operation: ImageMoveBackgroundOperation | None = Field(
default=None, description="The active background operation, if any."
)
active_job_id: int | None = Field(default=None, description="The active journal job id, if any.")
latest_job: ImageMoveJobResponse | None = Field(default=None, description="The latest journal job, if any.")
last_error: str | None = Field(default=None, description="The last background worker error, if any.")


def _get_image_move_service():
image_moves = getattr(ApiDependencies.invoker.services, "image_moves", None)
if image_moves is None:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Image move service unavailable")
return image_moves


def _job_to_response(job: ImageMoveJob | None) -> ImageMoveJobResponse | None:
if job is None:
return None
return ImageMoveJobResponse(id=job.id, state=job.state, error_message=job.error_message)


def _status_to_response(service_status: ImageMoveBackgroundStatus | dict) -> ImageMoveStatusResponse:
if isinstance(service_status, dict):
return ImageMoveStatusResponse(**service_status)
return ImageMoveStatusResponse(
is_running=service_status.is_running,
operation=service_status.operation,
active_job_id=service_status.active_job_id,
latest_job=_job_to_response(service_status.latest_job),
last_error=service_status.last_error,
)


@image_moves_router.post(
"/start",
operation_id="start_image_move",
response_model=ImageMoveStatusResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse:
try:
return _status_to_response(_get_image_move_service().start_background_move_all())
except (ImageMoveJobAlreadyRunning, ImageMoveQueueActive) as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e


@image_moves_router.post(
"/recover",
operation_id="start_image_move_recovery",
response_model=ImageMoveStatusResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusResponse:
try:
return _status_to_response(_get_image_move_service().start_background_recovery())
except ImageMoveJobAlreadyRunning as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e


@image_moves_router.get(
"/status",
operation_id="get_image_move_status",
response_model=ImageMoveStatusResponse,
)
async def get_image_move_status(_: AdminUserOrDefault) -> ImageMoveStatusResponse:
return _status_to_response(_get_image_move_service().get_background_status())
Loading
Loading