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
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Choose **Delete Board** from the board's menu. The confirmation dialog shows how
**Delete Board and Media** cannot be undone.
:::

With **Protect starred media** turned on in Settings, **Delete Board and Media** keeps the board's starred images and videos: they move to **Uncategorized** while the board and everything else on it is deleted. The dialog says how many starred items will be kept. See [Deleting media](/users-guide/gallery/introduction/#deleting-media).

Queued or running jobs that were sending results to the deleted board send them to **Uncategorized** instead. A project's Inbox cannot be deleted from the Gallery; delete the project instead, which also asks what to do with the project's other boards.

On a multi-user installation, only a board's owner or an administrator can delete it. When an owner deletes a shared board with its media, only their own media is deleted and other people's items move to Uncategorized; an administrator's deletion removes everything. If some files cannot be removed, those items are kept in Uncategorized and a warning says how many.
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ Drag items onto a board in the board list; if the list is hidden, it opens while

Press **Delete** (or **Backspace**), choose **Delete Image**, **Delete Video** or **Delete Selection** from the menu, or use the delete button in the selection bar. Deleting permanently removes the items from every board and from disk, and cannot be undone. A confirmation asks first. Tick **Don't ask me again** and confirm to stop asking (cancelling leaves it on); you can turn it back on, or off, with **Confirm image deletion** in Settings under Gallery.

To keep starred items safe from accidental deletion, turn on **Protect starred media** in the **Gallery** section of Settings. While it is on, deleting skips starred images and videos: from a mixed selection only the unstarred items are deleted, and when everything selected is starred nothing is deleted. If **Confirm image deletion** is on, a notice says how many starred items were kept; with it off you only get a notice when nothing could be deleted because everything selected was starred. Unstar an item to delete it. The setting is off by default.

Deleting does not check whether an image is still used elsewhere, for example on a canvas or as a reference image, so check before you delete media you are still working with. If deleting is interrupted, InvokeAI cleans up any leftover files the next time it starts.

## Related pages
Expand Down
27 changes: 23 additions & 4 deletions invokeai/app/api/routers/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ class DeleteBoardResult(BaseModel):
default_factory=list,
description="The names of videos that could not be deleted and became uncategorized.",
)
starred_images_skipped: list[str] = Field(
default_factory=list,
description="The names of starred images that were protected and became uncategorized.",
)
starred_videos_skipped: list[str] = Field(
default_factory=list,
description="The names of starred videos that were protected and became uncategorized.",
)


@boards_router.post(
Expand Down Expand Up @@ -145,6 +153,7 @@ def delete_board(
include_images: Optional[bool] = Query(
description="Permanently delete all images and videos on the board", default=False
),
delete_starred: bool = Query(default=True, description="Whether to allow deletion of starred media"),
) -> DeleteBoardResult:
"""Deletes a board (user must have access to it)"""
try:
Expand Down Expand Up @@ -206,11 +215,19 @@ def delete_board(
# as failures. This is the ground truth — reconstructing failures by diffing a
# router-side board listing against the deleted names would double the DB work and
# misreport items moved or deleted concurrently between the two queries.
deleted_images, failed_images = ApiDependencies.invoker.services.images.delete_images_by_names(
board_image_names
(
deleted_images,
failed_images,
starred_images_skipped,
) = ApiDependencies.invoker.services.images.delete_images_by_names(
board_image_names, delete_starred=delete_starred
)
deleted_videos, failed_videos = ApiDependencies.invoker.services.videos.delete_videos_by_names(
board_video_names
(
deleted_videos,
failed_videos,
starred_videos_skipped,
) = ApiDependencies.invoker.services.videos.delete_videos_by_names(
board_video_names, delete_starred=delete_starred
)
return DeleteBoardResult(
board_id=board_id,
Expand All @@ -220,6 +237,8 @@ def delete_board(
deleted_videos=deleted_videos,
failed_images=failed_images,
failed_videos=failed_videos,
starred_images_skipped=starred_images_skipped,
starred_videos_skipped=starred_videos_skipped,
)

return DeleteBoardResult(
Expand Down
32 changes: 22 additions & 10 deletions invokeai/app/api/routers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ def create_image_upload_entry(
def delete_image(
current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of the image to delete"),
delete_starred: bool = Query(default=True, description="Whether to allow deletion of starred images"),
) -> DeleteImagesResult:
"""Deletes an image"""
_assert_image_owner(image_name, current_user)
Expand All @@ -259,7 +260,7 @@ def delete_image(

board_id = image_dto.board_id or "none"
try:
ApiDependencies.invoker.services.images.delete(image_name)
was_deleted = ApiDependencies.invoker.services.images.delete(image_name, delete_starred=delete_starred)
except ImageRecordNotFoundException:
# Another request deleted the image between the lookup above and the service call. The
# image is gone, which is what the client asked for — answer as the lookup would have.
Expand All @@ -268,11 +269,12 @@ def delete_image(
raise HTTPException(status_code=500, detail="Failed to delete image")

return DeleteImagesResult(
deleted_images=[image_name],
deleted_images=[image_name] if was_deleted else [],
# Every failure path above raises, so a returned result always describes a completed
# delete; nothing can land in ``failed_images``.
# delete or a protected starred image; nothing can land in ``failed_images``.
failed_images=[],
affected_boards=[board_id],
affected_boards=[board_id] if was_deleted else [],
starred_skipped=[] if was_deleted else [image_name],
)


Expand Down Expand Up @@ -571,6 +573,7 @@ def delete_images_from_list(
image_names: list[ImageName] = Body(
description="The list of names of images to delete", embed=True, max_length=MAX_IMAGE_BATCH_SIZE
),
delete_starred: bool = Body(default=True, description="Whether to allow deletion of starred images"),
) -> DeleteImagesResult:
try:
assert_image_move_maintenance_inactive()
Expand All @@ -587,6 +590,7 @@ def delete_images_from_list(
deleted_images: set[str] = set()
failed_images: set[str] = set()
affected_boards: set[str] = set()
starred_skipped: set[str] = set()
# Dedup while preserving order: a name repeated in the request would otherwise
# be processed twice, and the second pass's not-found error would land the same
# name in both deleted_images and failed_images.
Expand All @@ -602,9 +606,11 @@ def delete_images_from_list(
_assert_image_owner(image_name, current_user)
image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
board_id = image_dto.board_id or "none"
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.add(image_name)
affected_boards.add(board_id)
if ApiDependencies.invoker.services.images.delete(image_name, delete_starred=delete_starred):
deleted_images.add(image_name)
affected_boards.add(board_id)
else:
starred_skipped.add(image_name)
except HTTPException:
continue
except ImageRecordNotFoundException:
Expand Down Expand Up @@ -642,6 +648,7 @@ def delete_images_from_list(
deleted_images=list(deleted_images),
failed_images=list(failed_images),
affected_boards=list(affected_boards),
starred_skipped=list(starred_skipped),
)
except HTTPException:
raise
Expand All @@ -652,6 +659,7 @@ def delete_images_from_list(
@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult)
def delete_uncategorized_images(
current_user: CurrentUserOrDefault,
delete_starred: bool = Query(default=True, description="Whether to allow deletion of starred images"),
) -> DeleteImagesResult:
"""Deletes all uncategorized images owned by the current user (or all if admin)"""
assert_image_move_maintenance_inactive()
Expand All @@ -664,12 +672,15 @@ def delete_uncategorized_images(
deleted_images: set[str] = set()
failed_images: set[str] = set()
affected_boards: set[str] = set()
starred_skipped: set[str] = set()
for image_name in image_names:
try:
_assert_image_owner(image_name, current_user)
ApiDependencies.invoker.services.images.delete(image_name)
deleted_images.add(image_name)
affected_boards.add("none")
if ApiDependencies.invoker.services.images.delete(image_name, delete_starred=delete_starred):
deleted_images.add(image_name)
affected_boards.add("none")
else:
starred_skipped.add(image_name)
except HTTPException:
# Skip images not owned by the current user
pass
Expand All @@ -679,6 +690,7 @@ def delete_uncategorized_images(
deleted_images=list(deleted_images),
failed_images=list(failed_images),
affected_boards=list(affected_boards),
starred_skipped=list(starred_skipped),
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete images")
Expand Down
35 changes: 25 additions & 10 deletions invokeai/app/api/routers/videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ class VideoNamesBatch(BaseModel):
)


class DeleteVideosBatch(VideoNamesBatch):
delete_starred: bool = Field(default=True, description="Whether to allow deletion of starred videos")


def _get_video_cache_control() -> str:
if ApiDependencies.invoker.services.configuration.multiuser:
return "private, no-store"
Expand Down Expand Up @@ -620,6 +624,7 @@ async def upload_video(
def delete_video(
current_user: CurrentUserOrDefault,
video_name: str = PathParam(description="The name of the video to delete"),
delete_starred: bool = Query(default=True, description="Whether to allow deletion of starred videos"),
) -> DeleteVideosResult:
_assert_video_owner(video_name, current_user)

Expand All @@ -635,21 +640,22 @@ def delete_video(

board_id = video_dto.board_id or "none"
try:
ApiDependencies.invoker.services.videos.delete(video_name)
was_deleted = ApiDependencies.invoker.services.videos.delete(video_name, delete_starred=delete_starred)
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete video")

return DeleteVideosResult(
deleted_videos=[video_name],
deleted_videos=[video_name] if was_deleted else [],
failed_videos=[],
affected_boards=[board_id],
affected_boards=[board_id] if was_deleted else [],
starred_skipped=[] if was_deleted else [video_name],
)


@videos_router.post("/delete", operation_id="delete_videos_from_list", response_model=DeleteVideosResult)
def delete_videos_from_list(
current_user: CurrentUserOrDefault,
batch: VideoNamesBatch,
batch: DeleteVideosBatch,
) -> DeleteVideosResult:
# Skip — but do not re-raise — auth failures so a foreign name mid-batch doesn't
# discard the response payload for items the caller had already legitimately deleted.
Expand All @@ -663,6 +669,7 @@ def delete_videos_from_list(
deleted_videos: set[str] = set()
failed_videos: set[str] = set()
affected_boards: set[str] = set()
starred_skipped: set[str] = set()
# Dedup while preserving order: a name repeated in the request would otherwise be
# processed twice, and the second pass's not-found error would land the same name
# in both deleted_videos and failed_videos.
Expand All @@ -671,9 +678,11 @@ def delete_videos_from_list(
_assert_video_owner(video_name, current_user)
video_dto = ApiDependencies.invoker.services.videos.get_dto(video_name)
board_id = video_dto.board_id or "none"
ApiDependencies.invoker.services.videos.delete(video_name)
deleted_videos.add(video_name)
affected_boards.add(board_id)
if ApiDependencies.invoker.services.videos.delete(video_name, delete_starred=batch.delete_starred):
deleted_videos.add(video_name)
affected_boards.add(board_id)
else:
starred_skipped.add(video_name)
except HTTPException:
continue
except Exception:
Expand All @@ -682,12 +691,14 @@ def delete_videos_from_list(
deleted_videos=list(deleted_videos),
failed_videos=list(failed_videos),
affected_boards=list(affected_boards),
starred_skipped=list(starred_skipped),
)


@videos_router.delete("/uncategorized", operation_id="delete_uncategorized_videos", response_model=DeleteVideosResult)
def delete_uncategorized_videos(
current_user: CurrentUserOrDefault,
delete_starred: bool = Query(default=True, description="Whether to allow deletion of starred videos"),
) -> DeleteVideosResult:
"""Deletes all uncategorized videos owned by the current user (or all if admin).

Expand All @@ -702,12 +713,15 @@ def delete_uncategorized_videos(
deleted_videos: set[str] = set()
failed_videos: set[str] = set()
affected_boards: set[str] = set()
starred_skipped: set[str] = set()
for video_name in names_result.video_names:
try:
_assert_video_owner(video_name, current_user)
ApiDependencies.invoker.services.videos.delete(video_name)
deleted_videos.add(video_name)
affected_boards.add("none")
if ApiDependencies.invoker.services.videos.delete(video_name, delete_starred=delete_starred):
deleted_videos.add(video_name)
affected_boards.add("none")
else:
starred_skipped.add(video_name)
except HTTPException:
# Skip videos not owned by the current user — an intentional skip, not a
# failed deletion, so it must not be reported (and toasted) as one.
Expand All @@ -718,6 +732,7 @@ def delete_uncategorized_videos(
deleted_videos=list(deleted_videos),
failed_videos=list(failed_videos),
affected_boards=list(affected_boards),
starred_skipped=list(starred_skipped),
)


Expand Down
8 changes: 8 additions & 0 deletions invokeai/app/services/image_records/image_records_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ def delete_many(self, image_names: list[str]) -> None:
"""Deletes many image records."""
pass

@abstractmethod
def delete_many_keeping_starred(self, image_names: list[str]) -> list[str]:
"""Deletes the named image records that are not starred, on one transaction with the starred check.

Returns the names kept because they are starred, including ones starred after the caller last looked.
"""
pass

@abstractmethod
def get_subfolders(self, image_names: list[str]) -> dict[str, str]:
"""Maps each existing named image to its on-disk subfolder; absent names are omitted."""
Expand Down
3 changes: 3 additions & 0 deletions invokeai/app/services/image_records/image_records_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ def delete(self, image_name: str) -> None:
def delete_many(self, image_names: list[str]) -> None:
self._queries.images.delete_many(image_names)

def delete_many_keeping_starred(self, image_names: list[str]) -> list[str]:
return self._queries.images.delete_many_keeping_starred(image_names)

def iter_all_image_locations(self, batch_size: int = 500) -> Iterator[tuple[str, str]]:
"""Yields every image location through bounded keyset pages, including hidden/intermediate rows."""
if batch_size <= 0:
Expand Down
19 changes: 13 additions & 6 deletions invokeai/app/services/images/images_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ def get_many(
pass

@abstractmethod
def delete(self, image_name: str):
"""Deletes an image."""
def delete(self, image_name: str, delete_starred: bool = True) -> bool:
"""Deletes an image, returning ``False`` when a starred image is protected."""
pass

@abstractmethod
Expand All @@ -171,8 +171,13 @@ def delete_intermediates_by_names(
pass

@abstractmethod
def delete_images_by_names(self, image_names: list[str]) -> tuple[list[str], list[str]]:
"""Deletes exactly these images; returns ``(deleted_names, failed_names)``.
def delete_images_by_names(
self, image_names: list[str], delete_starred: bool = True
) -> tuple[list[str], list[str], list[str]]:
"""Deletes exactly these images; returns ``(deleted_names, failed_names, starred_skipped_names)``.

When ``delete_starred`` is ``False``, starred images are preserved and reported in
``starred_skipped_names``.

For callers that must decide whether the deletion may proceed *before* destroying
anything, and so enumerate the names themselves. Same per-image failure semantics as
Expand All @@ -181,8 +186,10 @@ def delete_images_by_names(self, image_names: list[str]) -> tuple[list[str], lis
pass

@abstractmethod
def delete_images_on_board(self, board_id: str, user_id: Optional[str] = None) -> tuple[list[str], list[str]]:
"""Deletes all images on a board; returns ``(deleted_names, failed_names)``.
def delete_images_on_board(
self, board_id: str, user_id: Optional[str] = None, delete_starred: bool = True
) -> tuple[list[str], list[str], list[str]]:
"""Deletes images on a board; returns ``(deleted_names, failed_names, starred_skipped_names)``.

When ``user_id`` is provided, only images owned by that user are deleted (other users'
contributions to a public/shared board are preserved). Pass ``None`` for the admin
Expand Down
4 changes: 4 additions & 0 deletions invokeai/app/services/images/images_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class DeleteImagesResult(ResultWithAffectedBoards):
# Required, not defaulted: the client toasts off this field, and an optional one would reach
# it as `undefined`. Matches StarredImagesResult.
failed_images: list[str] = Field(description="The names of authorized images that could not be deleted")
starred_skipped: list[str] = Field(
default_factory=list,
description="The names of starred images that were skipped because deletion protection was enabled",
)


class StarredImagesResult(ResultWithAffectedBoards):
Expand Down
Loading
Loading