Merge branch 'main' into bugfix/ImageToLatentsInvocation_fp32_precision

This commit is contained in:
Lincoln Stein 2023-07-19 12:08:04 -04:00 committed by GitHub
commit 2fbc6dc315
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 3059 additions and 2814 deletions

View File

@ -24,11 +24,14 @@ async def create_board_image(
): ):
"""Creates a board_image""" """Creates a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to add to board") raise HTTPException(status_code=500, detail="Failed to add to board")
@board_images_router.delete( @board_images_router.delete(
"/", "/",
operation_id="remove_board_image", operation_id="remove_board_image",
@ -43,27 +46,10 @@ async def remove_board_image(
): ):
"""Deletes a board_image""" """Deletes a board_image"""
try: try:
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(board_id=board_id, image_name=image_name) result = ApiDependencies.invoker.services.board_images.remove_image_from_board(
board_id=board_id, image_name=image_name
)
return result return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@board_images_router.get(
"/{board_id}",
operation_id="list_board_images",
response_model=OffsetPaginatedResults[ImageDTO],
)
async def list_board_images(
board_id: str = Path(description="The id of the board"),
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of boards per page"),
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a list of images for a board"""
results = ApiDependencies.invoker.services.board_images.get_images_for_board(
board_id,
)
return results

View File

@ -1,16 +1,28 @@
from typing import Optional, Union from typing import Optional, Union
from fastapi import Body, HTTPException, Path, Query from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from invokeai.app.services.board_record_storage import BoardChanges from invokeai.app.services.board_record_storage import BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO from invokeai.app.services.models.board_record import BoardDTO
from ..dependencies import ApiDependencies from ..dependencies import ApiDependencies
boards_router = APIRouter(prefix="/v1/boards", tags=["boards"]) boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
class DeleteBoardResult(BaseModel):
board_id: str = Field(description="The id of the board that was deleted.")
deleted_board_images: list[str] = Field(
description="The image names of the board-images relationships that were deleted."
)
deleted_images: list[str] = Field(
description="The names of the images that were deleted."
)
@boards_router.post( @boards_router.post(
"/", "/",
operation_id="create_board", operation_id="create_board",
@ -69,25 +81,42 @@ async def update_board(
raise HTTPException(status_code=500, detail="Failed to update board") raise HTTPException(status_code=500, detail="Failed to update board")
@boards_router.delete("/{board_id}", operation_id="delete_board") @boards_router.delete(
"/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult
)
async def delete_board( async def delete_board(
board_id: str = Path(description="The id of board to delete"), board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query( include_images: Optional[bool] = Query(
description="Permanently delete all images on the board", default=False description="Permanently delete all images on the board", default=False
), ),
) -> None: ) -> DeleteBoardResult:
"""Deletes a board""" """Deletes a board"""
try: try:
if include_images is True: if include_images is True:
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
)
ApiDependencies.invoker.services.images.delete_images_on_board( ApiDependencies.invoker.services.images.delete_images_on_board(
board_id=board_id board_id=board_id
) )
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=[],
deleted_images=deleted_images,
)
else: else:
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id=board_id
)
ApiDependencies.invoker.services.boards.delete(board_id=board_id) ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=deleted_board_images,
deleted_images=[],
)
except Exception as e: except Exception as e:
# TODO: Does this need any exception handling at all? raise HTTPException(status_code=500, detail="Failed to delete board")
pass
@boards_router.get( @boards_router.get(
@ -115,3 +144,19 @@ async def list_boards(
status_code=400, status_code=400,
detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'", detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'",
) )
@boards_router.get(
"/{board_id}/image_names",
operation_id="list_all_board_image_names",
response_model=list[str],
)
async def list_all_board_image_names(
board_id: str = Path(description="The id of the board"),
) -> list[str]:
"""Gets a list of images for a board"""
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id,
)
return image_names

View File

@ -245,16 +245,16 @@ async def get_image_urls(
) )
async def list_image_dtos( async def list_image_dtos(
image_origin: Optional[ResourceOrigin] = Query( image_origin: Optional[ResourceOrigin] = Query(
default=None, description="The origin of images to list" default=None, description="The origin of images to list."
), ),
categories: Optional[list[ImageCategory]] = Query( categories: Optional[list[ImageCategory]] = Query(
default=None, description="The categories of image to include" default=None, description="The categories of image to include."
), ),
is_intermediate: Optional[bool] = Query( is_intermediate: Optional[bool] = Query(
default=None, description="Whether to list intermediate images" default=None, description="Whether to list intermediate images."
), ),
board_id: Optional[str] = Query( board_id: Optional[str] = Query(
default=None, description="The board id to filter by" default=None, description="The board id to filter by. Use 'none' to find images without a board."
), ),
offset: int = Query(default=0, description="The page offset"), offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of images per page"), limit: int = Query(default=10, description="The number of images per page"),

View File

@ -32,11 +32,11 @@ class BoardImageRecordStorageBase(ABC):
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageRecord]: ) -> list[str]:
"""Gets images for a board.""" """Gets all board images for a board, as a list of the image names."""
pass pass
@abstractmethod @abstractmethod
@ -211,6 +211,26 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
items=images, offset=offset, limit=limit, total=count items=images, offset=offset, limit=limit, total=count
) )
def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT image_name
FROM board_images
WHERE board_id = ?;
""",
(board_id,),
)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
image_names = list(map(lambda r: r[0], result))
return image_names
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
def get_board_for_image( def get_board_for_image(
self, self,
image_name: str, image_name: str,

View File

@ -38,11 +38,11 @@ class BoardImagesServiceABC(ABC):
pass pass
@abstractmethod @abstractmethod
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> list[str]:
"""Gets images for a board.""" """Gets all board images for a board, as a list of the image names."""
pass pass
@abstractmethod @abstractmethod
@ -98,30 +98,13 @@ class BoardImagesService(BoardImagesServiceABC):
) -> None: ) -> None:
self._services.board_image_records.remove_image_from_board(board_id, image_name) self._services.board_image_records.remove_image_from_board(board_id, image_name)
def get_images_for_board( def get_all_board_image_names_for_board(
self, self,
board_id: str, board_id: str,
) -> OffsetPaginatedResults[ImageDTO]: ) -> list[str]:
image_records = self._services.board_image_records.get_images_for_board( return self._services.board_image_records.get_all_board_image_names_for_board(
board_id board_id
) )
image_dtos = list(
map(
lambda r: image_record_to_dto(
r,
self._services.urls.get_image_url(r.image_name),
self._services.urls.get_image_url(r.image_name, True),
board_id,
),
image_records.items,
)
)
return OffsetPaginatedResults[ImageDTO](
items=image_dtos,
offset=image_records.offset,
limit=image_records.limit,
total=image_records.total,
)
def get_board_for_image( def get_board_for_image(
self, self,
@ -136,7 +119,7 @@ def board_record_to_dto(
) -> BoardDTO: ) -> BoardDTO:
"""Converts a board record to a board DTO.""" """Converts a board record to a board DTO."""
return BoardDTO( return BoardDTO(
**board_record.dict(exclude={'cover_image_name'}), **board_record.dict(exclude={"cover_image_name"}),
cover_image_name=cover_image_name, cover_image_name=cover_image_name,
image_count=image_count, image_count=image_count,
) )

View File

@ -10,7 +10,10 @@ from pydantic.generics import GenericModel
from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.services.models.image_record import ( from invokeai.app.services.models.image_record import (
ImageRecord, ImageRecordChanges, deserialize_image_record) ImageRecord,
ImageRecordChanges,
deserialize_image_record,
)
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
@ -377,11 +380,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
query_params.append(is_intermediate) query_params.append(is_intermediate)
if board_id is not None: # board_id of "none" is reserved for images without a board
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
elif board_id is not None:
query_conditions += """--sql query_conditions += """--sql
AND board_images.board_id = ? AND board_images.board_id = ?
""" """
query_params.append(board_id) query_params.append(board_id)
query_pagination = """--sql query_pagination = """--sql

View File

@ -11,7 +11,6 @@ from invokeai.app.models.image import (ImageCategory,
InvalidOriginException, ResourceOrigin) InvalidOriginException, ResourceOrigin)
from invokeai.app.services.board_image_record_storage import \ from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase BoardImageRecordStorageBase
from invokeai.app.services.graph import Graph
from invokeai.app.services.image_file_storage import ( from invokeai.app.services.image_file_storage import (
ImageFileDeleteException, ImageFileNotFoundException, ImageFileDeleteException, ImageFileNotFoundException,
ImageFileSaveException, ImageFileStorageBase) ImageFileSaveException, ImageFileStorageBase)
@ -385,16 +384,14 @@ class ImageService(ImageServiceABC):
def delete_images_on_board(self, board_id: str): def delete_images_on_board(self, board_id: str):
try: try:
images = self._services.board_image_records.get_images_for_board(board_id) image_names = (
image_name_list = list( self._services.board_image_records.get_all_board_image_names_for_board(
map( board_id
lambda r: r.image_name,
images.items,
) )
) )
for image_name in image_name_list: for image_name in image_names:
self._services.image_files.delete(image_name) self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list) self._services.image_records.delete_many(image_names)
except ImageRecordDeleteException: except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records") self._services.logger.error(f"Failed to delete image records")
raise raise

View File

@ -15,7 +15,6 @@ import InvokeTabs from 'features/ui/components/InvokeTabs';
import ParametersDrawer from 'features/ui/components/ParametersDrawer'; import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import i18n from 'i18n'; import i18n from 'i18n';
import { ReactNode, memo, useEffect } from 'react'; import { ReactNode, memo, useEffect } from 'react';
import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal';
import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal';
import GlobalHotkeys from './GlobalHotkeys'; import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster'; import Toaster from './Toaster';
@ -84,7 +83,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
</Grid> </Grid>
<DeleteImageModal /> <DeleteImageModal />
<UpdateImageBoardModal /> <UpdateImageBoardModal />
<DeleteBoardImagesModal />
<Toaster /> <Toaster />
<GlobalHotkeys /> <GlobalHotkeys />
</> </>

View File

@ -15,10 +15,7 @@ const STYLES: ChakraProps['sx'] = {
maxH: BOX_SIZE, maxH: BOX_SIZE,
shadow: 'dark-lg', shadow: 'dark-lg',
borderRadius: 'lg', borderRadius: 'lg',
borderWidth: 2, opacity: 0.3,
borderStyle: 'dashed',
borderColor: 'base.100',
opacity: 0.5,
bg: 'base.800', bg: 'base.800',
color: 'base.50', color: 'base.50',
_dark: { _dark: {

View File

@ -28,6 +28,7 @@ const ImageDndContext = (props: ImageDndContextProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
console.log('dragStart', event.active.data.current);
const activeData = event.active.data.current; const activeData = event.active.data.current;
if (!activeData) { if (!activeData) {
return; return;
@ -37,15 +38,16 @@ const ImageDndContext = (props: ImageDndContextProps) => {
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
console.log('dragEnd', event.active.data.current);
const activeData = event.active.data.current; const activeData = event.active.data.current;
const overData = event.over?.data.current; const overData = event.over?.data.current;
if (!activeData || !overData) { if (!activeDragData || !overData) {
return; return;
} }
dispatch(dndDropped({ overData, activeData })); dispatch(dndDropped({ overData, activeData: activeDragData }));
setActiveDragData(null); setActiveDragData(null);
}, },
[dispatch] [activeDragData, dispatch]
); );
const mouseSensor = useSensor(MouseSensor, { const mouseSensor = useSensor(MouseSensor, {

View File

@ -11,6 +11,7 @@ import {
useDraggable as useOriginalDraggable, useDraggable as useOriginalDraggable,
useDroppable as useOriginalDroppable, useDroppable as useOriginalDroppable,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { BoardId } from 'features/gallery/store/gallerySlice';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
type BaseDropData = { type BaseDropData = {
@ -55,7 +56,7 @@ export type AddToBatchDropData = BaseDropData & {
export type MoveBoardDropData = BaseDropData & { export type MoveBoardDropData = BaseDropData & {
actionType: 'MOVE_BOARD'; actionType: 'MOVE_BOARD';
context: { boardId: string | null }; context: { boardId: BoardId };
}; };
export type TypesafeDroppableData = export type TypesafeDroppableData =
@ -158,8 +159,36 @@ export const isValidDrop = (
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'ADD_TO_BATCH': case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD': case 'MOVE_BOARD': {
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES'; // If the board is the same, don't allow the drop
// Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
if (!isPayloadValid) {
return false;
}
// Check if the image's board is the board we are dragging onto
if (payloadType === 'IMAGE_DTO') {
const { imageDTO } = active.data.current.payload;
const currentBoard = imageDTO.board_id;
const destinationBoard = overData.context.boardId;
const isSameBoard = currentBoard === destinationBoard;
const isDestinationValid = !currentBoard
? destinationBoard !== 'no_board'
: true;
return !isSameBoard && isDestinationValid;
}
if (payloadType === 'IMAGE_NAMES') {
// TODO (multi-select)
return false;
}
return true;
}
default: default:
return false; return false;
} }

View File

@ -18,7 +18,6 @@ import { Middleware } from '@reduxjs/toolkit';
import ImageDndContext from './ImageDnd/ImageDndContext'; import ImageDndContext from './ImageDnd/ImageDndContext';
import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext'; import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext';
import { $authToken, $baseUrl } from 'services/api/client'; import { $authToken, $baseUrl } from 'services/api/client';
import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext';
const App = lazy(() => import('./App')); const App = lazy(() => import('./App'));
const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider'));
@ -78,9 +77,7 @@ const InvokeAIUI = ({
<ThemeLocaleProvider> <ThemeLocaleProvider>
<ImageDndContext> <ImageDndContext>
<AddImageToBoardContextProvider> <AddImageToBoardContextProvider>
<DeleteBoardImagesContextProvider>
<App config={config} headerComponent={headerComponent} /> <App config={config} headerComponent={headerComponent} />
</DeleteBoardImagesContextProvider>
</AddImageToBoardContextProvider> </AddImageToBoardContextProvider>
</ImageDndContext> </ImageDndContext>
</ThemeLocaleProvider> </ThemeLocaleProvider>

View File

@ -1,7 +1,8 @@
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react'; import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { useAppDispatch } from '../store/storeHooks';
export type ImageUsage = { export type ImageUsage = {
isInitialImage: boolean; isInitialImage: boolean;
@ -40,8 +41,7 @@ type Props = PropsWithChildren;
export const AddImageToBoardContextProvider = (props: Props) => { export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>(); const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const [addImageToBoard, result] = useAddImageToBoardMutation();
// Clean up after deleting or dismissing the modal // Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => { const closeAndClearImageToDelete = useCallback(() => {
@ -63,14 +63,16 @@ export const AddImageToBoardContextProvider = (props: Props) => {
const handleAddToBoard = useCallback( const handleAddToBoard = useCallback(
(boardId: string) => { (boardId: string) => {
if (imageToMove) { if (imageToMove) {
addImageToBoard({ dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO: imageToMove,
board_id: boardId, board_id: boardId,
image_name: imageToMove.image_name, })
}); );
closeAndClearImageToDelete(); closeAndClearImageToDelete();
} }
}, },
[addImageToBoard, closeAndClearImageToDelete, imageToMove] [dispatch, closeAndClearImageToDelete, imageToMove]
); );
return ( return (

View File

@ -1,170 +0,0 @@
import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { BoardDTO } from 'services/api/types';
import { useDeleteBoardMutation } from '../../services/api/endpoints/boards';
import { defaultSelectorOptions } from '../store/util/defaultMemoizeOptions';
import { createSelector } from '@reduxjs/toolkit';
import { some } from 'lodash-es';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
import { selectImagesById } from 'features/gallery/store/gallerySlice';
import { nodesSelector } from 'features/nodes/store/nodesSlice';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { RootState } from '../store/store';
import { useAppDispatch, useAppSelector } from '../store/storeHooks';
import { ImageUsage } from './DeleteImageContext';
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
export const selectBoardImagesUsage = createSelector(
[
(state: RootState) => state,
generationSelector,
canvasSelector,
nodesSelector,
controlNetSelector,
(state: RootState, board_id?: string) => board_id,
],
(state, generation, canvas, nodes, controlNet, board_id) => {
const initialImage = generation.initialImage
? selectImagesById(state, generation.initialImage.imageName)
: undefined;
const isInitialImage = initialImage?.board_id === board_id;
const isCanvasImage = canvas.layerState.objects.some((obj) => {
if (obj.kind === 'image') {
const image = selectImagesById(state, obj.imageName);
return image?.board_id === board_id;
}
return false;
});
const isNodesImage = nodes.nodes.some((node) => {
return some(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
const image = selectImagesById(state, input.value.image_name);
return image?.board_id === board_id;
}
return false;
});
});
const isControlNetImage = some(controlNet.controlNets, (c) => {
const controlImage = c.controlImage
? selectImagesById(state, c.controlImage)
: undefined;
const processedControlImage = c.processedControlImage
? selectImagesById(state, c.processedControlImage)
: undefined;
return (
controlImage?.board_id === board_id ||
processedControlImage?.board_id === board_id
);
});
const imageUsage: ImageUsage = {
isInitialImage,
isCanvasImage,
isNodesImage,
isControlNetImage,
};
return imageUsage;
},
defaultSelectorOptions
);
type DeleteBoardImagesContextValue = {
/**
* Whether the move image dialog is open.
*/
isOpen: boolean;
/**
* Closes the move image dialog.
*/
onClose: () => void;
imagesUsage?: ImageUsage;
board?: BoardDTO;
onClickDeleteBoardImages: (board: BoardDTO) => void;
handleDeleteBoardImages: (boardId: string) => void;
handleDeleteBoardOnly: (boardId: string) => void;
};
export const DeleteBoardImagesContext =
createContext<DeleteBoardImagesContextValue>({
isOpen: false,
onClose: () => undefined,
onClickDeleteBoardImages: () => undefined,
handleDeleteBoardImages: () => undefined,
handleDeleteBoardOnly: () => undefined,
});
type Props = PropsWithChildren;
export const DeleteBoardImagesContextProvider = (props: Props) => {
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
// Check where the board images to be deleted are used (eg init image, controlnet, etc.)
const imagesUsage = useAppSelector((state) =>
selectBoardImagesUsage(state, boardToDelete?.board_id)
);
const [deleteBoard] = useDeleteBoardMutation();
// Clean up after deleting or dismissing the modal
const closeAndClearBoardToDelete = useCallback(() => {
setBoardToDelete(undefined);
onClose();
}, [onClose]);
const onClickDeleteBoardImages = useCallback(
(board?: BoardDTO) => {
console.log({ board });
if (!board) {
return;
}
setBoardToDelete(board);
onOpen();
},
[setBoardToDelete, onOpen]
);
const handleDeleteBoardImages = useCallback(
(boardId: string) => {
if (boardToDelete) {
dispatch(
requestedBoardImagesDeletion({ board: boardToDelete, imagesUsage })
);
closeAndClearBoardToDelete();
}
},
[dispatch, closeAndClearBoardToDelete, boardToDelete, imagesUsage]
);
const handleDeleteBoardOnly = useCallback(
(boardId: string) => {
if (boardToDelete) {
deleteBoard(boardId);
closeAndClearBoardToDelete();
}
},
[deleteBoard, closeAndClearBoardToDelete, boardToDelete]
);
return (
<DeleteBoardImagesContext.Provider
value={{
isOpen,
board: boardToDelete,
onClose: closeAndClearBoardToDelete,
onClickDeleteBoardImages,
handleDeleteBoardImages,
handleDeleteBoardOnly,
imagesUsage,
}}
>
{props.children}
</DeleteBoardImagesContext.Provider>
);
};

View File

@ -11,7 +11,7 @@ import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingA
import { addAppConfigReceivedListener } from './listeners/appConfigReceived'; import { addAppConfigReceivedListener } from './listeners/appConfigReceived';
import { addAppStartedListener } from './listeners/appStarted'; import { addAppStartedListener } from './listeners/appStarted';
import { addBoardIdSelectedListener } from './listeners/boardIdSelected'; import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted'; import { addDeleteBoardAndImagesFulfilledListener } from './listeners/boardAndImagesDeleted';
import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard'; import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard';
import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage'; import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage';
import { addCanvasMergedListener } from './listeners/canvasMerged'; import { addCanvasMergedListener } from './listeners/canvasMerged';
@ -29,10 +29,6 @@ import {
addRequestedImageDeletionListener, addRequestedImageDeletionListener,
} from './listeners/imageDeleted'; } from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped'; import { addImageDroppedListener } from './listeners/imageDropped';
import {
addImageMetadataReceivedFulfilledListener,
addImageMetadataReceivedRejectedListener,
} from './listeners/imageMetadataReceived';
import { import {
addImageRemovedFromBoardFulfilledListener, addImageRemovedFromBoardFulfilledListener,
addImageRemovedFromBoardRejectedListener, addImageRemovedFromBoardRejectedListener,
@ -46,18 +42,10 @@ import {
addImageUploadedFulfilledListener, addImageUploadedFulfilledListener,
addImageUploadedRejectedListener, addImageUploadedRejectedListener,
} from './listeners/imageUploaded'; } from './listeners/imageUploaded';
import {
addImageUrlsReceivedFulfilledListener,
addImageUrlsReceivedRejectedListener,
} from './listeners/imageUrlsReceived';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected'; import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addModelSelectedListener } from './listeners/modelSelected'; import { addModelSelectedListener } from './listeners/modelSelected';
import { addModelsLoadedListener } from './listeners/modelsLoaded'; import { addModelsLoadedListener } from './listeners/modelsLoaded';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import {
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import { import {
addSessionCanceledFulfilledListener, addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener, addSessionCanceledPendingListener,
@ -91,6 +79,7 @@ import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextTo
import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted'; import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted';
import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted'; import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted';
import { addUpscaleRequestedListener } from './listeners/upscaleRequested'; import { addUpscaleRequestedListener } from './listeners/upscaleRequested';
import { addFirstListImagesListener } from './listeners/addFirstListImagesListener.ts';
export const listenerMiddleware = createListenerMiddleware(); export const listenerMiddleware = createListenerMiddleware();
@ -132,17 +121,9 @@ addRequestedImageDeletionListener();
addImageDeletedPendingListener(); addImageDeletedPendingListener();
addImageDeletedFulfilledListener(); addImageDeletedFulfilledListener();
addImageDeletedRejectedListener(); addImageDeletedRejectedListener();
addRequestedBoardImageDeletionListener(); addDeleteBoardAndImagesFulfilledListener();
addImageToDeleteSelectedListener(); addImageToDeleteSelectedListener();
// Image metadata
addImageMetadataReceivedFulfilledListener();
addImageMetadataReceivedRejectedListener();
// Image URLs
addImageUrlsReceivedFulfilledListener();
addImageUrlsReceivedRejectedListener();
// User Invoked // User Invoked
addUserInvokedCanvasListener(); addUserInvokedCanvasListener();
addUserInvokedNodesListener(); addUserInvokedNodesListener();
@ -198,17 +179,10 @@ addSessionCanceledPendingListener();
addSessionCanceledFulfilledListener(); addSessionCanceledFulfilledListener();
addSessionCanceledRejectedListener(); addSessionCanceledRejectedListener();
// Fetching images
addReceivedPageOfImagesFulfilledListener();
addReceivedPageOfImagesRejectedListener();
// ControlNet // ControlNet
addControlNetImageProcessedListener(); addControlNetImageProcessedListener();
addControlNetAutoProcessListener(); addControlNetAutoProcessListener();
// Update image URLs on connect
// addUpdateImageUrlsOnConnectListener();
// Boards // Boards
addImageAddedToBoardFulfilledListener(); addImageAddedToBoardFulfilledListener();
addImageAddedToBoardRejectedListener(); addImageAddedToBoardRejectedListener();
@ -229,5 +203,7 @@ addModelSelectedListener();
addAppStartedListener(); addAppStartedListener();
addModelsLoadedListener(); addModelsLoadedListener();
addAppConfigReceivedListener(); addAppConfigReceivedListener();
addFirstListImagesListener();
// Ad-hoc upscale workflwo
addUpscaleRequestedListener(); addUpscaleRequestedListener();

View File

@ -0,0 +1,43 @@
import { createAction } from '@reduxjs/toolkit';
import {
IMAGE_CATEGORIES,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import {
ImageCache,
getListImagesUrl,
imagesApi,
} from 'services/api/endpoints/images';
import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted');
export const addFirstListImagesListener = () => {
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: async (
action,
{ getState, dispatch, unsubscribe, cancelActiveListeners }
) => {
// Only run this listener on the first listImages request for `images` categories
if (
action.meta.arg.queryCacheKey !==
getListImagesUrl({ categories: IMAGE_CATEGORIES })
) {
return;
}
// this should only run once
cancelActiveListeners();
unsubscribe();
// TODO: figure out how to type the predicate
const data = action.payload as ImageCache;
if (data.ids.length > 0) {
// Select the first image
dispatch(imageSelected(data.ids[0] as string));
}
},
});
};

View File

@ -1,11 +1,4 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
isLoadingChanged,
} from 'features/gallery/store/gallerySlice';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..'; import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted'); export const appStarted = createAction('app/appStarted');
@ -17,29 +10,9 @@ export const addAppStartedListener = () => {
action, action,
{ getState, dispatch, unsubscribe, cancelActiveListeners } { getState, dispatch, unsubscribe, cancelActiveListeners }
) => { ) => {
// this should only run once
cancelActiveListeners(); cancelActiveListeners();
unsubscribe(); unsubscribe();
// fill up the gallery tab with images
await dispatch(
receivedPageOfImages({
categories: IMAGE_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
// fill up the assets tab with images
await dispatch(
receivedPageOfImages({
categories: ASSETS_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
dispatch(isLoadingChanged(false));
}, },
}); });
}; };

View File

@ -0,0 +1,48 @@
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { getImageUsage } from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { startAppListening } from '..';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addDeleteBoardAndImagesFulfilledListener = () => {
startAppListening({
matcher: boardsApi.endpoints.deleteBoardAndImages.matchFulfilled,
effect: async (action, { dispatch, getState, condition }) => {
const { board_id, deleted_board_images, deleted_images } = action.payload;
// Remove all deleted images from the UI
let wasInitialImageReset = false;
let wasCanvasReset = false;
let wasNodeEditorReset = false;
let wasControlNetReset = false;
const state = getState();
deleted_images.forEach((image_name) => {
const imageUsage = getImageUsage(state, image_name);
if (imageUsage.isInitialImage && !wasInitialImageReset) {
dispatch(clearInitialImage());
wasInitialImageReset = true;
}
if (imageUsage.isCanvasImage && !wasCanvasReset) {
dispatch(resetCanvas());
wasCanvasReset = true;
}
if (imageUsage.isNodesImage && !wasNodeEditorReset) {
dispatch(nodeEditorReset());
wasNodeEditorReset = true;
}
if (imageUsage.isControlNetImage && !wasControlNetReset) {
dispatch(controlNetReset());
wasControlNetReset = true;
}
});
},
});
};

View File

@ -1,17 +1,13 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected, boardIdSelected,
imageSelected, imageSelected,
selectImagesAll,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { boardsApi } from 'services/api/endpoints/boards';
import { import {
IMAGES_PER_PAGE, getBoardIdQueryParamForBoard,
receivedPageOfImages, getCategoriesQueryParamForBoard,
} from 'services/api/thunks/image'; } from 'features/gallery/store/util';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
@ -19,54 +15,44 @@ const moduleLog = log.child({ namespace: 'boards' });
export const addBoardIdSelectedListener = () => { export const addBoardIdSelectedListener = () => {
startAppListening({ startAppListening({
actionCreator: boardIdSelected, actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => { effect: async (
const board_id = action.payload; action,
{ getState, dispatch, condition, cancelActiveListeners }
) => {
// Cancel any in-progress instances of this listener, we don't want to select an image from a previous board
cancelActiveListeners();
// we need to check if we need to fetch more images const _board_id = action.payload;
// when a board is selected, we need to wait until the board has loaded *some* images, then select the first one
const state = getState(); const categories = getCategoriesQueryParamForBoard(_board_id);
const allImages = selectImagesAll(state); const board_id = getBoardIdQueryParamForBoard(_board_id);
const queryArgs = { board_id, categories };
if (board_id === 'all') { // wait until the board has some images - maybe it already has some from a previous fetch
// Selected all images // must use getState() to ensure we do not have stale state
dispatch(imageSelected(allImages[0]?.image_name ?? null)); const isSuccess = await condition(
return; () =>
} imagesApi.endpoints.listImages.select(queryArgs)(getState())
.isSuccess,
if (board_id === 'batch') { 1000
// Selected the batch
dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null));
return;
}
const filteredImages = selectFilteredImages(state);
const categories =
state.gallery.galleryView === 'images'
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// get the board from the cache
const { data: boards } =
boardsApi.endpoints.listAllBoards.select()(state);
const board = boards?.find((b) => b.board_id === board_id);
if (!board) {
// can't find the board in cache...
dispatch(boardIdSelected('all'));
return;
}
dispatch(imageSelected(board.cover_image_name ?? null));
// if we haven't loaded one full page of images from this board, load more
if (
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
); );
if (isSuccess) {
// the board was just changed - we can select the first image
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(
queryArgs
)(getState());
if (boardImagesData?.ids.length) {
dispatch(imageSelected((boardImagesData.ids[0] as string) ?? null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));
}
} else {
// fallback - deselect
dispatch(imageSelected(null));
} }
}, },
}); });

View File

@ -1,82 +0,0 @@
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import {
imageSelected,
imagesRemoved,
selectImagesAll,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { LIST_TAG, api } from 'services/api';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addRequestedBoardImageDeletionListener = () => {
startAppListening({
actionCreator: requestedBoardImagesDeletion,
effect: async (action, { dispatch, getState, condition }) => {
const { board, imagesUsage } = action.payload;
const { board_id } = board;
const state = getState();
const selectedImageName =
state.gallery.selection[state.gallery.selection.length - 1];
const selectedImage = selectedImageName
? selectImagesById(state, selectedImageName)
: undefined;
if (selectedImage && selectedImage.board_id === board_id) {
dispatch(imageSelected(null));
}
// We need to reset the features where the board images are in use - none of these work if their image(s) don't exist
if (imagesUsage.isCanvasImage) {
dispatch(resetCanvas());
}
if (imagesUsage.isControlNetImage) {
dispatch(controlNetReset());
}
if (imagesUsage.isInitialImage) {
dispatch(clearInitialImage());
}
if (imagesUsage.isNodesImage) {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
const images = selectImagesAll(state).reduce((acc: string[], img) => {
if (img.board_id === board_id) {
acc.push(img.image_name);
}
return acc;
}, []);
dispatch(imagesRemoved(images));
// Delete from server
dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id));
const result =
boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state);
const { isSuccess } = result;
// Wait for successful deletion, then trigger boards to re-fetch
const wasBoardDeleted = await condition(() => !!isSuccess, 30000);
if (wasBoardDeleted) {
dispatch(
api.util.invalidateTags([
{ type: 'Board', id: board_id },
{ type: 'Image', id: LIST_TAG },
])
);
}
},
});
};

View File

@ -1,11 +1,11 @@
import { canvasMerged } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { addToast } from 'features/system/store/systemSlice'; import { canvasMerged } from 'features/canvas/store/actions';
import { imageUploaded } from 'services/api/thunks/image';
import { setMergedCanvas } from 'features/canvas/store/canvasSlice'; import { setMergedCanvas } from 'features/canvas/store/canvasSlice';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob'; import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' });
@ -46,27 +46,28 @@ export const addCanvasMergedListener = () => {
}); });
const imageUploadedRequest = dispatch( const imageUploadedRequest = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'mergedCanvas.png', { file: new File([blob], 'mergedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
image_category: 'general', image_category: 'general',
is_intermediate: true, is_intermediate: true,
postUploadAction: { postUploadAction: {
type: 'TOAST_CANVAS_MERGED', type: 'TOAST',
toastOptions: { title: 'Canvas Merged' },
}, },
}) })
); );
const [{ payload }] = await take( const [{ payload }] = await take(
( (uploadedImageAction) =>
uploadedImageAction imagesApi.endpoints.uploadImage.matchFulfilled(uploadedImageAction) &&
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
); );
const { image_name } = payload; // TODO: I can't figure out how to do the type narrowing in the `take()` so just brute forcing it here
const { image_name } =
payload as typeof imagesApi.endpoints.uploadImage.Types.ResultType;
dispatch( dispatch(
setMergedCanvas({ setMergedCanvas({
@ -76,13 +77,6 @@ export const addCanvasMergedListener = () => {
...baseLayerRect, ...baseLayerRect,
}) })
); );
dispatch(
addToast({
title: 'Canvas Merged',
status: 'success',
})
);
}, },
}); });
}; };

View File

@ -1,10 +1,9 @@
import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUploaded } from 'services/api/thunks/image'; import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUpserted } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
@ -28,28 +27,19 @@ export const addCanvasSavedToGalleryListener = () => {
return; return;
} }
const imageUploadedRequest = dispatch( dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'savedCanvas.png', { file: new File([blob], 'savedCanvas.png', {
type: 'image/png', type: 'image/png',
}), }),
image_category: 'general', image_category: 'general',
is_intermediate: false, is_intermediate: false,
postUploadAction: { postUploadAction: {
type: 'TOAST_CANVAS_SAVED_TO_GALLERY', type: 'TOAST',
toastOptions: { title: 'Canvas Saved to Gallery' },
}, },
}) })
); );
const [{ payload: uploadedImageDTO }] = await take(
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
dispatch(imageUpserted(uploadedImageDTO));
}, },
}); });
}; };

View File

@ -2,10 +2,10 @@ import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions'; import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { sessionReadyToInvoke } from 'features/system/store/actions'; import { sessionReadyToInvoke } from 'features/system/store/actions';
import { imagesApi } from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards'; import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCreated } from 'services/api/thunks/session'; import { sessionCreated } from 'services/api/thunks/session';
import { Graph } from 'services/api/types'; import { Graph, ImageDTO } from 'services/api/types';
import { socketInvocationComplete } from 'services/events/actions'; import { socketInvocationComplete } from 'services/events/actions';
import { startAppListening } from '..'; import { startAppListening } from '..';
@ -62,12 +62,13 @@ export const addControlNetImageProcessedListener = () => {
invocationCompleteAction.payload.data.result.image; invocationCompleteAction.payload.data.result.image;
// Wait for the ImageDTO to be received // Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> => (action) =>
imageDTOReceived.fulfilled.match(action) && imagesApi.endpoints.getImageDTO.matchFulfilled(action) &&
action.payload.image_name === image_name action.payload.image_name === image_name
); );
const processedControlImage = imageMetadataReceivedAction.payload;
const processedControlImage = payload as ImageDTO;
moduleLog.debug( moduleLog.debug(
{ data: { arg: action.payload, processedControlImage } }, { data: { arg: action.payload, processedControlImage } },

View File

@ -1,31 +1,30 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
export const addImageAddedToBoardFulfilledListener = () => { export const addImageAddedToBoardFulfilledListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled, matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, imageDTO } = action.meta.arg.originalArgs;
moduleLog.debug( // TODO: update listImages cache for this board
{ data: { board_id, image_name } },
'Image added to board' moduleLog.debug({ data: { board_id, imageDTO } }, 'Image added to board');
);
}, },
}); });
}; };
export const addImageAddedToBoardRejectedListener = () => { export const addImageAddedToBoardRejectedListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected, matcher: imagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, imageDTO } = action.meta.arg.originalArgs;
moduleLog.debug( moduleLog.debug(
{ data: { board_id, image_name } }, { data: { board_id, imageDTO } },
'Problem adding image to board' 'Problem adding image to board'
); );
}, },

View File

@ -1,19 +1,17 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice'; import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors'; import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { import { imageSelected } from 'features/gallery/store/gallerySlice';
imageRemoved,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import { import {
imageDeletionConfirmed, imageDeletionConfirmed,
isModalOpenChanged, isModalOpenChanged,
} from 'features/imageDeletion/store/imageDeletionSlice'; } from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { clamp } from 'lodash-es';
import { api } from 'services/api'; import { api } from 'services/api';
import { imageDeleted } from 'services/api/thunks/image'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
@ -36,10 +34,28 @@ export const addRequestedImageDeletionListener = () => {
state.gallery.selection[state.gallery.selection.length - 1]; state.gallery.selection[state.gallery.selection.length - 1];
if (lastSelectedImage === image_name) { if (lastSelectedImage === image_name) {
const newSelectedImageId = selectNextImageToSelect(state, image_name); const baseQueryArgs = selectListImagesBaseQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const ids = data?.ids ?? [];
const deletedImageIndex = ids.findIndex(
(result) => result.toString() === image_name
);
const filteredIds = ids.filter((id) => id.toString() !== image_name);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
if (newSelectedImageId) { if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImageId)); dispatch(imageSelected(newSelectedImageId as string));
} else { } else {
dispatch(imageSelected(null)); dispatch(imageSelected(null));
} }
@ -63,16 +79,15 @@ export const addRequestedImageDeletionListener = () => {
dispatch(nodeEditorReset()); dispatch(nodeEditorReset());
} }
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// Delete from server // Delete from server
const { requestId } = dispatch(imageDeleted({ image_name })); const { requestId } = dispatch(
imagesApi.endpoints.deleteImage.initiate(imageDTO)
);
// Wait for successful deletion, then trigger boards to re-fetch // Wait for successful deletion, then trigger boards to re-fetch
const wasImageDeleted = await condition( const wasImageDeleted = await condition(
(action): action is ReturnType<typeof imageDeleted.fulfilled> => (action) =>
imageDeleted.fulfilled.match(action) && imagesApi.endpoints.deleteImage.matchFulfilled(action) &&
action.meta.requestId === requestId, action.meta.requestId === requestId,
30000 30000
); );
@ -91,7 +106,7 @@ export const addRequestedImageDeletionListener = () => {
*/ */
export const addImageDeletedPendingListener = () => { export const addImageDeletedPendingListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.pending, matcher: imagesApi.endpoints.deleteImage.matchPending,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
// //
}, },
@ -103,9 +118,12 @@ export const addImageDeletedPendingListener = () => {
*/ */
export const addImageDeletedFulfilledListener = () => { export const addImageDeletedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.fulfilled, matcher: imagesApi.endpoints.deleteImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug({ data: { image: action.meta.arg } }, 'Image deleted'); moduleLog.debug(
{ data: { image: action.meta.arg.originalArgs } },
'Image deleted'
);
}, },
}); });
}; };
@ -115,10 +133,10 @@ export const addImageDeletedFulfilledListener = () => {
*/ */
export const addImageDeletedRejectedListener = () => { export const addImageDeletedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageDeleted.rejected, matcher: imagesApi.endpoints.deleteImage.matchRejected,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug( moduleLog.debug(
{ data: { image: action.meta.arg } }, { data: { image: action.meta.arg.originalArgs } },
'Unable to delete image' 'Unable to delete image'
); );
}, },

View File

@ -10,12 +10,9 @@ import {
imageSelected, imageSelected,
imagesAddedToBatch, imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
fieldValueChanged,
imageCollectionFieldValueChanged,
} from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '../'; import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' }); const moduleLog = log.child({ namespace: 'dnd' });
@ -137,23 +134,23 @@ export const addImageDroppedListener = () => {
return; return;
} }
// set multiple nodes images (multiple images handler) // // set multiple nodes images (multiple images handler)
if ( // if (
overData.actionType === 'SET_MULTI_NODES_IMAGE' && // overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_NAMES' // activeData.payloadType === 'IMAGE_NAMES'
) { // ) {
const { fieldName, nodeId } = overData.context; // const { fieldName, nodeId } = overData.context;
dispatch( // dispatch(
imageCollectionFieldValueChanged({ // imageCollectionFieldValueChanged({
nodeId, // nodeId,
fieldName, // fieldName,
value: activeData.payload.image_names.map((image_name) => ({ // value: activeData.payload.image_names.map((image_name) => ({
image_name, // image_name,
})), // })),
}) // })
); // );
return; // return;
} // }
// add image to board // add image to board
if ( if (
@ -162,97 +159,95 @@ export const addImageDroppedListener = () => {
activeData.payload.imageDTO && activeData.payload.imageDTO &&
overData.context.boardId overData.context.boardId
) { ) {
const { image_name } = activeData.payload.imageDTO; const { imageDTO } = activeData.payload;
const { boardId } = overData.context; const { boardId } = overData.context;
// if the board is "No Board", this is a remove action
if (boardId === 'no_board') {
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ imagesApi.endpoints.removeImageFromBoard.initiate({
image_name, imageDTO,
})
);
return;
}
// Handle adding image to batch
if (boardId === 'batch') {
// TODO
}
// Otherwise, add the image to the board
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO,
board_id: boardId, board_id: boardId,
}) })
); );
return; return;
} }
// remove image from board // // add gallery selection to board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' && // activeData.payloadType === 'IMAGE_NAMES' &&
activeData.payload.imageDTO && // overData.context.boardId
overData.context.boardId === null // ) {
) { // console.log('adding gallery selection to board');
const { image_name, board_id } = activeData.payload.imageDTO; // const board_id = overData.context.boardId;
if (board_id) { // dispatch(
dispatch( // boardImagesApi.endpoints.addManyBoardImages.initiate({
boardImagesApi.endpoints.removeImageFromBoard.initiate({ // board_id,
image_name, // image_names: activeData.payload.image_names,
board_id, // })
}) // );
); // return;
} // }
return;
}
// add gallery selection to board // // remove gallery selection from board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId // overData.context.boardId === null
) { // ) {
console.log('adding gallery selection to board'); // console.log('removing gallery selection to board');
const board_id = overData.context.boardId; // dispatch(
dispatch( // boardImagesApi.endpoints.deleteManyBoardImages.initiate({
boardImagesApi.endpoints.addManyBoardImages.initiate({ // image_names: activeData.payload.image_names,
board_id, // })
image_names: activeData.payload.image_names, // );
}) // return;
); // }
return;
}
// remove gallery selection from board // // add batch selection to board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null // overData.context.boardId
) { // ) {
console.log('removing gallery selection to board'); // const board_id = overData.context.boardId;
dispatch( // dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({ // boardImagesApi.endpoints.addManyBoardImages.initiate({
image_names: activeData.payload.image_names, // board_id,
}) // image_names: activeData.payload.image_names,
); // })
return; // );
} // return;
// }
// add batch selection to board // // remove batch selection from board
if ( // if (
overData.actionType === 'MOVE_BOARD' && // overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' && // activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId // overData.context.boardId === null
) { // ) {
const board_id = overData.context.boardId; // dispatch(
dispatch( // boardImagesApi.endpoints.deleteManyBoardImages.initiate({
boardImagesApi.endpoints.addManyBoardImages.initiate({ // image_names: activeData.payload.image_names,
board_id, // })
image_names: activeData.payload.image_names, // );
}) // return;
); // }
return;
}
// remove batch selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
}, },
}); });
}; };

View File

@ -1,51 +0,0 @@
import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' });
export const addImageMetadataReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageDTOReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
const state = getState();
if (
image.session_id === state.canvas.layerState.stagingArea.sessionId &&
state.canvas.shouldAutoSave
) {
dispatch(
imageUpdated({
image_name: image.image_name,
is_intermediate: image.is_intermediate,
})
);
} else if (image.is_intermediate) {
// No further actions needed for intermediate images
moduleLog.trace(
{ data: { image } },
'Image metadata received (intermediate), skipping'
);
return;
}
moduleLog.debug({ data: { image } }, 'Image metadata received');
dispatch(imageUpserted(image));
},
});
};
export const addImageMetadataReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageDTOReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem receiving image metadata'
);
},
});
};

View File

@ -1,12 +1,12 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..'; import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' }); const moduleLog = log.child({ namespace: 'boards' });
export const addImageRemovedFromBoardFulfilledListener = () => { export const addImageRemovedFromBoardFulfilledListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled, matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, image_name } = action.meta.arg.originalArgs;
@ -20,7 +20,7 @@ export const addImageRemovedFromBoardFulfilledListener = () => {
export const addImageRemovedFromBoardRejectedListener = () => { export const addImageRemovedFromBoardRejectedListener = () => {
startAppListening({ startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected, matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected,
effect: (action, { getState, dispatch }) => { effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs; const { board_id, image_name } = action.meta.arg.originalArgs;

View File

@ -1,15 +1,20 @@
import { startAppListening } from '..';
import { imageUpdated } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
export const addImageUpdatedFulfilledListener = () => { export const addImageUpdatedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUpdated.fulfilled, matcher: imagesApi.endpoints.updateImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
moduleLog.debug( moduleLog.debug(
{ oldImage: action.meta.arg, updatedImage: action.payload }, {
data: {
oldImage: action.meta.arg.originalArgs,
updatedImage: action.payload,
},
},
'Image updated' 'Image updated'
); );
}, },
@ -18,9 +23,12 @@ export const addImageUpdatedFulfilledListener = () => {
export const addImageUpdatedRejectedListener = () => { export const addImageUpdatedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUpdated.rejected, matcher: imagesApi.endpoints.updateImage.matchRejected,
effect: (action, { dispatch }) => { effect: (action, { dispatch }) => {
moduleLog.debug({ oldImage: action.meta.arg }, 'Image update failed'); moduleLog.debug(
{ data: action.meta.arg.originalArgs },
'Image update failed'
);
}, },
}); });
}; };

View File

@ -1,49 +1,87 @@
import { UseToastOptions } from '@chakra-ui/react';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice'; import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
imageUpserted,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice'; import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imageUploaded } from 'services/api/thunks/image'; import { boardsApi } from 'services/api/endpoints/boards';
import { startAppListening } from '..'; import { startAppListening } from '..';
import {
SYSTEM_BOARDS,
imagesApi,
} from '../../../../../services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'image' }); const moduleLog = log.child({ namespace: 'image' });
const DEFAULT_UPLOADED_TOAST: UseToastOptions = {
title: 'Image Uploaded',
status: 'success',
};
export const addImageUploadedFulfilledListener = () => { export const addImageUploadedFulfilledListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUploaded.fulfilled, matcher: imagesApi.endpoints.uploadImage.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
const image = action.payload; const imageDTO = action.payload;
const state = getState();
const { selectedBoardId } = state.gallery;
moduleLog.debug({ arg: '<Blob>', image }, 'Image uploaded'); moduleLog.debug({ arg: '<Blob>', imageDTO }, 'Image uploaded');
if (action.payload.is_intermediate) { const { postUploadAction } = action.meta.arg.originalArgs;
// No further actions needed for intermediate images
if (
// No further actions needed for intermediate images,
action.payload.is_intermediate &&
// unless they have an explicit post-upload action
!postUploadAction
) {
return; return;
} }
dispatch(imageUpserted(image)); // default action - just upload and alert user
if (postUploadAction?.type === 'TOAST') {
const { postUploadAction } = action.meta.arg; const { toastOptions } = postUploadAction;
if (SYSTEM_BOARDS.includes(selectedBoardId)) {
if (postUploadAction?.type === 'TOAST_CANVAS_SAVED_TO_GALLERY') { dispatch(addToast({ ...DEFAULT_UPLOADED_TOAST, ...toastOptions }));
} else {
// Add this image to the board
dispatch( dispatch(
addToast({ title: 'Canvas Saved to Gallery', status: 'success' }) imagesApi.endpoints.addImageToBoard.initiate({
board_id: selectedBoardId,
imageDTO,
})
); );
return;
}
if (postUploadAction?.type === 'TOAST_CANVAS_MERGED') { // Attempt to get the board's name for the toast
dispatch(addToast({ title: 'Canvas Merged', status: 'success' })); const { data } = boardsApi.endpoints.listAllBoards.select()(state);
// Fall back to just the board id if we can't find the board for some reason
const board = data?.find((b) => b.board_id === selectedBoardId);
const description = board
? `Added to board ${board.board_name}`
: `Added to board ${selectedBoardId}`;
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description,
})
);
}
return; return;
} }
if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') { if (postUploadAction?.type === 'SET_CANVAS_INITIAL_IMAGE') {
dispatch(setInitialCanvasImage(image)); dispatch(setInitialCanvasImage(imageDTO));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as canvas initial image',
})
);
return; return;
} }
@ -52,30 +90,49 @@ export const addImageUploadedFulfilledListener = () => {
dispatch( dispatch(
controlNetImageChanged({ controlNetImageChanged({
controlNetId, controlNetId,
controlImage: image.image_name, controlImage: imageDTO.image_name,
})
);
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as control image',
}) })
); );
return; return;
} }
if (postUploadAction?.type === 'SET_INITIAL_IMAGE') { if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
dispatch(initialImageChanged(image)); dispatch(initialImageChanged(imageDTO));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as initial image',
})
);
return; return;
} }
if (postUploadAction?.type === 'SET_NODES_IMAGE') { if (postUploadAction?.type === 'SET_NODES_IMAGE') {
const { nodeId, fieldName } = postUploadAction; const { nodeId, fieldName } = postUploadAction;
dispatch(fieldValueChanged({ nodeId, fieldName, value: image })); dispatch(fieldValueChanged({ nodeId, fieldName, value: imageDTO }));
return; dispatch(
} addToast({
...DEFAULT_UPLOADED_TOAST,
if (postUploadAction?.type === 'TOAST_UPLOADED') { description: `Set as node field ${fieldName}`,
dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); })
);
return; return;
} }
if (postUploadAction?.type === 'ADD_TO_BATCH') { if (postUploadAction?.type === 'ADD_TO_BATCH') {
dispatch(imagesAddedToBatch([image.image_name])); dispatch(imagesAddedToBatch([imageDTO.image_name]));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Added to batch',
})
);
return; return;
} }
}, },
@ -84,10 +141,10 @@ export const addImageUploadedFulfilledListener = () => {
export const addImageUploadedRejectedListener = () => { export const addImageUploadedRejectedListener = () => {
startAppListening({ startAppListening({
actionCreator: imageUploaded.rejected, matcher: imagesApi.endpoints.uploadImage.matchRejected,
effect: (action, { dispatch }) => { effect: (action, { dispatch }) => {
const { formData, ...rest } = action.meta.arg; const { file, postUploadAction, ...rest } = action.meta.arg.originalArgs;
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } }; const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
moduleLog.error({ data: sanitizedData }, 'Image upload failed'); moduleLog.error({ data: sanitizedData }, 'Image upload failed');
dispatch( dispatch(
addToast({ addToast({

View File

@ -1,37 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { imageUpdatedOne } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
export const addImageUrlsReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
moduleLog.debug({ data: { image } }, 'Image URLs received');
const { image_name, image_url, thumbnail_url } = image;
dispatch(
imageUpdatedOne({
id: image_name,
changes: { image_url, thumbnail_url },
})
);
},
});
};
export const addImageUrlsReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem getting image URLs'
);
},
});
};

View File

@ -1,11 +1,9 @@
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { t } from 'i18next';
import { addToast } from 'features/system/store/systemSlice';
import { startAppListening } from '..';
import { initialImageSelected } from 'features/parameters/store/actions';
import { makeToast } from 'app/components/Toaster'; import { makeToast } from 'app/components/Toaster';
import { selectImagesById } from 'features/gallery/store/gallerySlice'; import { initialImageSelected } from 'features/parameters/store/actions';
import { isImageDTO } from 'services/api/guards'; import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { t } from 'i18next';
import { startAppListening } from '..';
export const addInitialImageSelectedListener = () => { export const addInitialImageSelectedListener = () => {
startAppListening({ startAppListening({
@ -20,26 +18,8 @@ export const addInitialImageSelectedListener = () => {
return; return;
} }
if (isImageDTO(action.payload)) {
dispatch(initialImageChanged(action.payload)); dispatch(initialImageChanged(action.payload));
dispatch(addToast(makeToast(t('toast.sentToImageToImage')))); dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
return;
}
const imageName = action.payload;
const image = selectImagesById(getState(), imageName);
if (!image) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
dispatch(initialImageChanged(image));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
}, },
}); });
}; };

View File

@ -1,40 +0,0 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { serializeError } from 'serialize-error';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { imagesApi } from 'services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedPageOfImagesFulfilledListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
const { items } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Received ${items.length} images`
);
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
};
export const addReceivedPageOfImagesRejectedListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem receiving images'
);
}
},
});
};

View File

@ -1,9 +1,17 @@
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import {
IMAGE_CATEGORIES,
boardIdSelected,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import { progressImageSet } from 'features/system/store/systemSlice'; import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages'; import {
SYSTEM_BOARDS,
imagesAdapter,
imagesApi,
} from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards'; import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session'; import { sessionCanceled } from 'services/api/thunks/session';
import { import {
appSocketInvocationComplete, appSocketInvocationComplete,
@ -22,7 +30,6 @@ export const addInvocationCompleteEventListener = () => {
{ data: action.payload }, { data: action.payload },
`Invocation complete (${action.payload.data.node.type})` `Invocation complete (${action.payload.data.node.type})`
); );
const session_id = action.payload.data.graph_execution_state_id; const session_id = action.payload.data.graph_execution_state_id;
const { cancelType, isCancelScheduled, boardIdToAddTo } = const { cancelType, isCancelScheduled, boardIdToAddTo } =
@ -39,35 +46,72 @@ export const addInvocationCompleteEventListener = () => {
// This complete event has an associated image output // This complete event has an associated image output
if (isImageOutput(result) && !nodeDenylist.includes(node.type)) { if (isImageOutput(result) && !nodeDenylist.includes(node.type)) {
const { image_name } = result.image; const { image_name } = result.image;
const { canvas, gallery } = getState();
// Get its metadata const imageDTO = await dispatch(
dispatch( imagesApi.endpoints.getImageDTO.initiate(image_name)
imageDTOReceived({ ).unwrap();
image_name,
})
);
const [{ payload: imageDTO }] = await take( // Add canvas images to the staging area
imageDTOReceived.fulfilled.match
);
// Handle canvas image
if ( if (
graph_execution_state_id === graph_execution_state_id === canvas.layerState.stagingArea.sessionId
getState().canvas.layerState.stagingArea.sessionId
) { ) {
dispatch(addImageToStagingArea(imageDTO)); dispatch(addImageToStagingArea(imageDTO));
} }
if (boardIdToAddTo && !imageDTO.is_intermediate) { if (!imageDTO.is_intermediate) {
// update the cache for 'All Images'
dispatch( dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({ imagesApi.util.updateQueryData(
'listImages',
{
categories: IMAGE_CATEGORIES,
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
// update the cache for 'No Board'
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
board_id: 'none',
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
}
)
);
// add image to the board if we had one selected
if (boardIdToAddTo && !SYSTEM_BOARDS.includes(boardIdToAddTo)) {
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
board_id: boardIdToAddTo, board_id: boardIdToAddTo,
image_name, imageDTO,
}) })
); );
} }
const { selectedBoardId } = gallery;
if (boardIdToAddTo && boardIdToAddTo !== selectedBoardId) {
dispatch(boardIdSelected(boardIdToAddTo));
} else if (!boardIdToAddTo) {
dispatch(boardIdSelected('all'));
}
// If auto-switch is enabled, select the new image
if (getState().gallery.shouldAutoSwitch) {
dispatch(imageSelected(imageDTO.image_name));
}
}
dispatch(progressImageSet(null)); dispatch(progressImageSet(null));
} }
// pass along the socket event as an application action // pass along the socket event as an application action

View File

@ -1,9 +1,8 @@
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { imageUpdated } from 'services/api/thunks/image'; import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { addToast } from 'features/system/store/systemSlice'; import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'canvas' }); const moduleLog = log.child({ namespace: 'canvas' });
@ -11,41 +10,27 @@ export const addStagingAreaImageSavedListener = () => {
startAppListening({ startAppListening({
actionCreator: stagingAreaImageSaved, actionCreator: stagingAreaImageSaved,
effect: async (action, { dispatch, getState, take }) => { effect: async (action, { dispatch, getState, take }) => {
const { imageName } = action.payload; const { imageDTO } = action.payload;
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: imageName, imageDTO,
is_intermediate: false, changes: { is_intermediate: false },
}) })
); )
.unwrap()
const [imageUpdatedAction] = await take( .then((image) => {
(action) => dispatch(addToast({ title: 'Image Saved', status: 'success' }));
(imageUpdated.fulfilled.match(action) || })
imageUpdated.rejected.match(action)) && .catch((error) => {
action.meta.arg.image_name === imageName
);
if (imageUpdated.rejected.match(imageUpdatedAction)) {
moduleLog.error(
{ data: { arg: imageUpdatedAction.meta.arg } },
'Image saving failed'
);
dispatch( dispatch(
addToast({ addToast({
title: 'Image Saving Failed', title: 'Image Saving Failed',
description: imageUpdatedAction.error.message, description: error.message,
status: 'error', status: 'error',
}) })
); );
return; });
}
if (imageUpdated.fulfilled.match(imageUpdatedAction)) {
dispatch(imageUpserted(imageUpdatedAction.payload));
dispatch(addToast({ title: 'Image Saved', status: 'success' }));
}
}, },
}); });
}; };

View File

@ -1,91 +0,0 @@
import { socketConnected } from 'services/events/actions';
import { startAppListening } from '..';
import { createSelector } from '@reduxjs/toolkit';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import { nodesSelector } from 'features/nodes/store/nodesSlice';
import { controlNetSelector } from 'features/controlNet/store/controlNetSlice';
import { forEach, uniqBy } from 'lodash-es';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger';
import { selectImagesEntities } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'images' });
const selectAllUsedImages = createSelector(
[
generationSelector,
canvasSelector,
nodesSelector,
controlNetSelector,
selectImagesEntities,
],
(generation, canvas, nodes, controlNet, imageEntities) => {
const allUsedImages: string[] = [];
if (generation.initialImage) {
allUsedImages.push(generation.initialImage.imageName);
}
canvas.layerState.objects.forEach((obj) => {
if (obj.kind === 'image') {
allUsedImages.push(obj.imageName);
}
});
nodes.nodes.forEach((node) => {
forEach(node.data.inputs, (input) => {
if (input.type === 'image' && input.value) {
allUsedImages.push(input.value.image_name);
}
});
});
forEach(controlNet.controlNets, (c) => {
if (c.controlImage) {
allUsedImages.push(c.controlImage);
}
if (c.processedControlImage) {
allUsedImages.push(c.processedControlImage);
}
});
forEach(imageEntities, (image) => {
if (image) {
allUsedImages.push(image.image_name);
}
});
const uniqueImages = uniqBy(allUsedImages, 'image_name');
return uniqueImages;
}
);
export const addUpdateImageUrlsOnConnectListener = () => {
startAppListening({
actionCreator: socketConnected,
effect: async (action, { dispatch, getState, take }) => {
const state = getState();
if (!state.config.shouldUpdateImagesOnConnect) {
return;
}
const allUsedImages = selectAllUsedImages(state);
moduleLog.trace(
{ data: allUsedImages },
`Fetching new image URLs for ${allUsedImages.length} images`
);
allUsedImages.forEach((image_name) => {
dispatch(
imageUrlsReceived({
image_name,
})
);
});
},
});
};

View File

@ -1,20 +1,20 @@
import { startAppListening } from '..';
import { sessionCreated } from 'services/api/thunks/session';
import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { canvasGraphBuilt } from 'features/nodes/store/actions'; import { userInvoked } from 'app/store/actions';
import { imageUpdated, imageUploaded } from 'services/api/thunks/image'; import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import { ImageDTO } from 'services/api/types';
import { import {
canvasSessionIdChanged, canvasSessionIdChanged,
stagingAreaInitialized, stagingAreaInitialized,
} from 'features/canvas/store/canvasSlice'; } from 'features/canvas/store/canvasSlice';
import { userInvoked } from 'app/store/actions'; import { blobToDataURL } from 'features/canvas/util/blobToDataURL';
import { getCanvasData } from 'features/canvas/util/getCanvasData'; import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode'; import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode';
import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; import { canvasGraphBuilt } from 'features/nodes/store/actions';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; import { buildCanvasGraph } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
import { sessionReadyToInvoke } from 'features/system/store/actions'; import { sessionReadyToInvoke } from 'features/system/store/actions';
import { imagesApi } from 'services/api/endpoints/images';
import { sessionCreated } from 'services/api/thunks/session';
import { ImageDTO } from 'services/api/types';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'invoke' }); const moduleLog = log.child({ namespace: 'invoke' });
@ -74,7 +74,7 @@ export const addUserInvokedCanvasListener = () => {
if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) { if (['img2img', 'inpaint', 'outpaint'].includes(generationMode)) {
// upload the image, saving the request id // upload the image, saving the request id
const { requestId: initImageUploadedRequestId } = dispatch( const { requestId: initImageUploadedRequestId } = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([baseBlob], 'canvasInitImage.png', { file: new File([baseBlob], 'canvasInitImage.png', {
type: 'image/png', type: 'image/png',
}), }),
@ -85,19 +85,20 @@ export const addUserInvokedCanvasListener = () => {
// Wait for the image to be uploaded, matching by request id // Wait for the image to be uploaded, matching by request id
const [{ payload }] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> => // TODO: figure out how to narrow this action's type
imageUploaded.fulfilled.match(action) && (action) =>
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
action.meta.requestId === initImageUploadedRequestId action.meta.requestId === initImageUploadedRequestId
); );
canvasInitImage = payload; canvasInitImage = payload as ImageDTO;
} }
// For inpaint/outpaint, we also need to upload the mask layer // For inpaint/outpaint, we also need to upload the mask layer
if (['inpaint', 'outpaint'].includes(generationMode)) { if (['inpaint', 'outpaint'].includes(generationMode)) {
// upload the image, saving the request id // upload the image, saving the request id
const { requestId: maskImageUploadedRequestId } = dispatch( const { requestId: maskImageUploadedRequestId } = dispatch(
imageUploaded({ imagesApi.endpoints.uploadImage.initiate({
file: new File([maskBlob], 'canvasMaskImage.png', { file: new File([maskBlob], 'canvasMaskImage.png', {
type: 'image/png', type: 'image/png',
}), }),
@ -108,12 +109,13 @@ export const addUserInvokedCanvasListener = () => {
// Wait for the image to be uploaded, matching by request id // Wait for the image to be uploaded, matching by request id
const [{ payload }] = await take( const [{ payload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> => // TODO: figure out how to narrow this action's type
imageUploaded.fulfilled.match(action) && (action) =>
imagesApi.endpoints.uploadImage.matchFulfilled(action) &&
action.meta.requestId === maskImageUploadedRequestId action.meta.requestId === maskImageUploadedRequestId
); );
canvasMaskImage = payload; canvasMaskImage = payload as ImageDTO;
} }
const graph = buildCanvasGraph( const graph = buildCanvasGraph(
@ -144,9 +146,9 @@ export const addUserInvokedCanvasListener = () => {
// Associate the init image with the session, now that we have the session ID // Associate the init image with the session, now that we have the session ID
if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) { if (['img2img', 'inpaint'].includes(generationMode) && canvasInitImage) {
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: canvasInitImage.image_name, imageDTO: canvasInitImage,
session_id: sessionId, changes: { session_id: sessionId },
}) })
); );
} }
@ -154,9 +156,9 @@ export const addUserInvokedCanvasListener = () => {
// Associate the mask image with the session, now that we have the session ID // Associate the mask image with the session, now that we have the session ID
if (['inpaint'].includes(generationMode) && canvasMaskImage) { if (['inpaint'].includes(generationMode) && canvasMaskImage) {
dispatch( dispatch(
imageUpdated({ imagesApi.endpoints.updateImage.initiate({
image_name: canvasMaskImage.image_name, imageDTO: canvasMaskImage,
session_id: sessionId, changes: { session_id: sessionId },
}) })
); );
} }

View File

@ -11,13 +11,15 @@ import {
TypesafeDroppableData, TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import {
IAILoadingImageFallback,
IAINoContentFallback,
} from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react'; import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image'; import { ImageDTO, PostUploadAction } from 'services/api/types';
import { ImageDTO } from 'services/api/types';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import IAIDraggable from './IAIDraggable'; import IAIDraggable from './IAIDraggable';
import IAIDroppable from './IAIDroppable'; import IAIDroppable from './IAIDroppable';
@ -46,6 +48,7 @@ type IAIDndImageProps = {
isSelected?: boolean; isSelected?: boolean;
thumbnail?: boolean; thumbnail?: boolean;
noContentFallback?: ReactElement; noContentFallback?: ReactElement;
useThumbailFallback?: boolean;
}; };
const IAIDndImage = (props: IAIDndImageProps) => { const IAIDndImage = (props: IAIDndImageProps) => {
@ -71,6 +74,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
resetTooltip = 'Reset', resetTooltip = 'Reset',
resetIcon = <FaUndo />, resetIcon = <FaUndo />,
noContentFallback = <IAINoContentFallback icon={FaImage} />, noContentFallback = <IAINoContentFallback icon={FaImage} />,
useThumbailFallback,
} = props; } = props;
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
@ -126,9 +130,14 @@ const IAIDndImage = (props: IAIDndImageProps) => {
<Image <Image
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url} src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError" fallbackStrategy="beforeLoadOrError"
// If we fall back to thumbnail, it feels much snappier than the skeleton... fallbackSrc={
fallbackSrc={imageDTO.thumbnail_url} useThumbailFallback ? imageDTO.thumbnail_url : undefined
// fallback={<IAILoadingImageFallback image={imageDTO} />} }
fallback={
useThumbailFallback ? undefined : (
<IAILoadingImageFallback image={imageDTO} />
)
}
width={imageDTO.width} width={imageDTO.width}
height={imageDTO.height} height={imageDTO.height}
onError={onError} onError={onError}

View File

@ -1,12 +1,12 @@
import { Flex, Text, useColorMode } from '@chakra-ui/react'; import { Flex, Text, useColorMode } from '@chakra-ui/react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { memo, useRef } from 'react'; import { ReactNode, memo, useRef } from 'react';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
type Props = { type Props = {
isOver: boolean; isOver: boolean;
label?: string; label?: ReactNode;
}; };
export const IAIDropOverlay = (props: Props) => { export const IAIDropOverlay = (props: Props) => {
@ -57,16 +57,16 @@ export const IAIDropOverlay = (props: Props) => {
<Flex <Flex
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 0, top: 0.5,
insetInlineStart: 0, insetInlineStart: 0.5,
w: 'full', insetInlineEnd: 0.5,
h: 'full', bottom: 0.5,
opacity: 1, opacity: 1,
borderWidth: 3, borderWidth: 2,
borderColor: isOver borderColor: isOver
? mode('base.50', 'base.200')(colorMode) ? mode('base.50', 'base.50')(colorMode)
: mode('base.100', 'base.500')(colorMode), : mode('base.200', 'base.300')(colorMode),
borderRadius: 'base', borderRadius: 'lg',
borderStyle: 'dashed', borderStyle: 'dashed',
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
@ -78,10 +78,10 @@ export const IAIDropOverlay = (props: Props) => {
sx={{ sx={{
fontSize: '2xl', fontSize: '2xl',
fontWeight: 600, fontWeight: 600,
transform: isOver ? 'scale(1.02)' : 'scale(1)', transform: isOver ? 'scale(1.1)' : 'scale(1)',
color: isOver color: isOver
? mode('base.50', 'base.50')(colorMode) ? mode('base.50', 'base.50')(colorMode)
: mode('base.100', 'base.200')(colorMode), : mode('base.200', 'base.300')(colorMode),
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
}} }}

View File

@ -5,12 +5,12 @@ import {
useDroppable, useDroppable,
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { memo, useRef } from 'react'; import { ReactNode, memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay'; import IAIDropOverlay from './IAIDropOverlay';
type IAIDroppableProps = { type IAIDroppableProps = {
dropLabel?: string; dropLabel?: ReactNode;
disabled?: boolean; disabled?: boolean;
data?: TypesafeDroppableData; data?: TypesafeDroppableData;
}; };

View File

@ -68,6 +68,7 @@ export const IAINoContentFallback = (props: IAINoImageFallbackProps) => {
flexDir: 'column', flexDir: 'column',
gap: 2, gap: 2,
userSelect: 'none', userSelect: 'none',
opacity: 0.7,
color: 'base.700', color: 'base.700',
_dark: { _dark: {
color: 'base.500', color: 'base.500',

View File

@ -32,17 +32,46 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
> >
<Flex <Flex
sx={{ sx={{
opacity: 0.4, position: 'absolute',
width: '100%', top: 0,
height: '100%', insetInlineStart: 0,
flexDirection: 'column', w: 'full',
rowGap: 4, h: 'full',
bg: 'base.700',
_dark: { bg: 'base.900' },
opacity: 0.7,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
bg: 'base.900', transitionProperty: 'common',
boxShadow: `inset 0 0 20rem 1rem var(--invokeai-colors-${ transitionDuration: '0.1s',
isDragAccept ? 'accent' : 'error' }}
}-500)`, />
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
p: 4,
}}
>
<Flex
sx={{
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
flexDir: 'column',
gap: 4,
borderWidth: 3,
borderRadius: 'xl',
borderStyle: 'dashed',
color: 'base.100',
borderColor: 'base.100',
_dark: { borderColor: 'base.200' },
}} }}
> >
{isDragAccept ? ( {isDragAccept ? (
@ -54,6 +83,7 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
</> </>
)} )}
</Flex> </Flex>
</Flex>
</Box> </Box>
); );
}; };

View File

@ -1,35 +1,43 @@
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { createSelector } from '@reduxjs/toolkit';
import useImageUploader from 'common/hooks/useImageUploader'; import { useAppToaster } from 'app/components/Toaster';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { selectIsBusy } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { import {
KeyboardEvent, KeyboardEvent,
memo,
ReactNode, ReactNode,
memo,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
} from 'react'; } from 'react';
import { FileRejection, useDropzone } from 'react-dropzone'; import { FileRejection, useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { imageUploaded } from 'services/api/thunks/image'; import { useUploadImageMutation } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/types';
import ImageUploadOverlay from './ImageUploadOverlay'; import ImageUploadOverlay from './ImageUploadOverlay';
import { useAppToaster } from 'app/components/Toaster'; import { AnimatePresence, motion } from 'framer-motion';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
const selector = createSelector( const selector = createSelector(
[systemSelector, activeTabNameSelector], [activeTabNameSelector],
(system, activeTabName) => { (activeTabName) => {
const { isConnected, isUploading } = system; let postUploadAction: PostUploadAction = { type: 'TOAST' };
const isUploaderDisabled = !isConnected || isUploading; if (activeTabName === 'unifiedCanvas') {
postUploadAction = { type: 'SET_CANVAS_INITIAL_IMAGE' };
}
if (activeTabName === 'img2img') {
postUploadAction = { type: 'SET_INITIAL_IMAGE' };
}
return { return {
isUploaderDisabled, postUploadAction,
activeTabName,
}; };
} },
defaultSelectorOptions
); );
type ImageUploaderProps = { type ImageUploaderProps = {
@ -38,12 +46,13 @@ type ImageUploaderProps = {
const ImageUploader = (props: ImageUploaderProps) => { const ImageUploader = (props: ImageUploaderProps) => {
const { children } = props; const { children } = props;
const dispatch = useAppDispatch(); const { postUploadAction } = useAppSelector(selector);
const { isUploaderDisabled, activeTabName } = useAppSelector(selector); const isBusy = useAppSelector(selectIsBusy);
const toaster = useAppToaster(); const toaster = useAppToaster();
const { t } = useTranslation(); const { t } = useTranslation();
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false); const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const { setOpenUploaderFunction } = useImageUploader();
const [uploadImage] = useUploadImageMutation();
const fileRejectionCallback = useCallback( const fileRejectionCallback = useCallback(
(rejection: FileRejection) => { (rejection: FileRejection) => {
@ -60,16 +69,14 @@ const ImageUploader = (props: ImageUploaderProps) => {
const fileAcceptedCallback = useCallback( const fileAcceptedCallback = useCallback(
async (file: File) => { async (file: File) => {
dispatch( uploadImage({
imageUploaded({
file, file,
image_category: 'user', image_category: 'user',
is_intermediate: false, is_intermediate: false,
postUploadAction: { type: 'TOAST_UPLOADED' }, postUploadAction,
}) });
);
}, },
[dispatch] [postUploadAction, uploadImage]
); );
const onDrop = useCallback( const onDrop = useCallback(
@ -101,13 +108,12 @@ const ImageUploader = (props: ImageUploaderProps) => {
isDragReject, isDragReject,
isDragActive, isDragActive,
inputRef, inputRef,
open,
} = useDropzone({ } = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] }, accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true, noClick: true,
onDrop, onDrop,
onDragOver: () => setIsHandlingUpload(true), onDragOver: () => setIsHandlingUpload(true),
disabled: isUploaderDisabled, disabled: isBusy,
multiple: false, multiple: false,
}); });
@ -126,19 +132,13 @@ const ImageUploader = (props: ImageUploaderProps) => {
} }
}; };
// Set the open function so we can open the uploader from anywhere
setOpenUploaderFunction(open);
// Add the paste event listener // Add the paste event listener
document.addEventListener('paste', handlePaste); document.addEventListener('paste', handlePaste);
return () => { return () => {
document.removeEventListener('paste', handlePaste); document.removeEventListener('paste', handlePaste);
setOpenUploaderFunction(() => {
return;
});
}; };
}, [inputRef, open, setOpenUploaderFunction]); }, [inputRef]);
return ( return (
<Box <Box
@ -150,13 +150,30 @@ const ImageUploader = (props: ImageUploaderProps) => {
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{children} {children}
<AnimatePresence>
{isDragActive && isHandlingUpload && ( {isDragActive && isHandlingUpload && (
<motion.div
key="image-upload-overlay"
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.1 },
}}
>
<ImageUploadOverlay <ImageUploadOverlay
isDragAccept={isDragAccept} isDragAccept={isDragAccept}
isDragReject={isDragReject} isDragReject={isDragReject}
setIsHandlingUpload={setIsHandlingUpload} setIsHandlingUpload={setIsHandlingUpload}
/> />
</motion.div>
)} )}
</AnimatePresence>
</Box> </Box>
); );
}; };

View File

@ -1,49 +0,0 @@
import { Flex, Heading, Icon } from '@chakra-ui/react';
import useImageUploader from 'common/hooks/useImageUploader';
import { FaUpload } from 'react-icons/fa';
type ImageUploaderButtonProps = {
styleClass?: string;
};
const ImageUploaderButton = (props: ImageUploaderButtonProps) => {
const { styleClass } = props;
const { openUploader } = useImageUploader();
return (
<Flex
sx={{
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
className={styleClass}
>
<Flex
onClick={openUploader}
sx={{
display: 'flex',
flexDirection: 'column',
rowGap: 8,
p: 8,
borderRadius: 'base',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
cursor: 'pointer',
color: 'base.600',
bg: 'base.800',
_hover: {
bg: 'base.700',
},
}}
>
<Icon as={FaUpload} boxSize={24} />
<Heading size="md">Click or Drag and Drop</Heading>
</Flex>
</Flex>
);
};
export default ImageUploaderButton;

View File

@ -1,20 +0,0 @@
import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa';
import IAIIconButton from './IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader';
const ImageUploaderIconButton = () => {
const { t } = useTranslation();
const { openUploader } = useImageUploader();
return (
<IAIIconButton
aria-label={t('accessibility.uploadImage')}
tooltip="Upload Image"
icon={<FaUpload />}
onClick={openUploader}
/>
);
};
export default ImageUploaderIconButton;

View File

@ -1,7 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { PostUploadAction, imageUploaded } from 'services/api/thunks/image'; import { useUploadImageMutation } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/types';
type UseImageUploadButtonArgs = { type UseImageUploadButtonArgs = {
postUploadAction?: PostUploadAction; postUploadAction?: PostUploadAction;
@ -12,7 +12,7 @@ type UseImageUploadButtonArgs = {
* Provides image uploader functionality to any component. * Provides image uploader functionality to any component.
* *
* @example * @example
* const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ * const { getUploadButtonProps, getUploadInputProps, openUploader } = useImageUploadButton({
* postUploadAction: { * postUploadAction: {
* type: 'SET_CONTROLNET_IMAGE', * type: 'SET_CONTROLNET_IMAGE',
* controlNetId: '12345', * controlNetId: '12345',
@ -20,6 +20,9 @@ type UseImageUploadButtonArgs = {
* isDisabled: getIsUploadDisabled(), * isDisabled: getIsUploadDisabled(),
* }); * });
* *
* // open the uploaded directly
* const handleSomething = () => { openUploader() }
*
* // in the render function * // in the render function
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click * <Button {...getUploadButtonProps()} /> // will open the file dialog on click
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality * <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
@ -28,24 +31,23 @@ export const useImageUploadButton = ({
postUploadAction, postUploadAction,
isDisabled, isDisabled,
}: UseImageUploadButtonArgs) => { }: UseImageUploadButtonArgs) => {
const dispatch = useAppDispatch(); const [uploadImage] = useUploadImageMutation();
const onDropAccepted = useCallback( const onDropAccepted = useCallback(
(files: File[]) => { (files: File[]) => {
const file = files[0]; const file = files[0];
if (!file) { if (!file) {
return; return;
} }
dispatch( uploadImage({
imageUploaded({
file, file,
image_category: 'user', image_category: 'user',
is_intermediate: false, is_intermediate: false,
postUploadAction, postUploadAction: postUploadAction ?? { type: 'TOAST' },
}) });
);
}, },
[dispatch, postUploadAction] [postUploadAction, uploadImage]
); );
const { const {

View File

@ -1,23 +0,0 @@
import { useCallback } from 'react';
let openUploader = () => {
return;
};
const useImageUploader = () => {
const setOpenUploaderFunction = useCallback(
(openUploaderFunction?: () => void) => {
if (openUploaderFunction) {
openUploader = openUploaderFunction;
}
},
[]
);
return {
setOpenUploaderFunction,
openUploader,
};
};
export default useImageUploader;

View File

@ -26,6 +26,8 @@ import {
FaSave, FaSave,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { stagingAreaImageSaved } from '../store/actions'; import { stagingAreaImageSaved } from '../store/actions';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
const selector = createSelector( const selector = createSelector(
[canvasSelector], [canvasSelector],
@ -123,6 +125,10 @@ const IAICanvasStagingAreaToolbar = () => {
[dispatch, sessionId] [dispatch, sessionId]
); );
const { data: imageDTO } = useGetImageDTOQuery(
currentStagingAreaImage?.imageName ?? skipToken
);
if (!currentStagingAreaImage) return null; if (!currentStagingAreaImage) return null;
return ( return (
@ -173,14 +179,19 @@ const IAICanvasStagingAreaToolbar = () => {
<IAIIconButton <IAIIconButton
tooltip={t('unifiedCanvas.saveToGallery')} tooltip={t('unifiedCanvas.saveToGallery')}
aria-label={t('unifiedCanvas.saveToGallery')} aria-label={t('unifiedCanvas.saveToGallery')}
isDisabled={!imageDTO || !imageDTO.is_intermediate}
icon={<FaSave />} icon={<FaSave />}
onClick={() => onClick={() => {
if (!imageDTO) {
return;
}
dispatch( dispatch(
stagingAreaImageSaved({ stagingAreaImageSaved({
imageName: currentStagingAreaImage.imageName, imageDTO,
}) })
) );
} }}
colorScheme="accent" colorScheme="accent"
/> />
<IAIIconButton <IAIIconButton

View File

@ -2,7 +2,6 @@ import { Box, ButtonGroup, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader';
import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick'; import { useSingleAndDoubleClick } from 'common/hooks/useSingleAndDoubleClick';
import { import {
canvasSelector, canvasSelector,
@ -25,6 +24,7 @@ import { systemSelector } from 'features/system/store/systemSelectors';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { import {
canvasCopiedToClipboard, canvasCopiedToClipboard,
canvasDownloadedAsImage, canvasDownloadedAsImage,
@ -82,7 +82,9 @@ const IAICanvasToolbar = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isClipboardAPIAvailable } = useCopyImageToClipboard(); const { isClipboardAPIAvailable } = useCopyImageToClipboard();
const { openUploader } = useImageUploader(); const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
});
useHotkeys( useHotkeys(
['v'], ['v'],
@ -288,9 +290,10 @@ const IAICanvasToolbar = () => {
aria-label={`${t('common.upload')}`} aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`} tooltip={`${t('common.upload')}`}
icon={<FaUpload />} icon={<FaUpload />}
onClick={openUploader}
isDisabled={isStaging} isDisabled={isStaging}
{...getUploadButtonProps()}
/> />
<input {...getUploadInputProps()} />
<IAIIconButton <IAIIconButton
aria-label={`${t('unifiedCanvas.clearCanvas')}`} aria-label={`${t('unifiedCanvas.clearCanvas')}`}
tooltip={`${t('unifiedCanvas.clearCanvas')}`} tooltip={`${t('unifiedCanvas.clearCanvas')}`}

View File

@ -1,4 +1,5 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import { ImageDTO } from 'services/api/types';
export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery'); export const canvasSavedToGallery = createAction('canvas/canvasSavedToGallery');
@ -12,6 +13,6 @@ export const canvasDownloadedAsImage = createAction(
export const canvasMerged = createAction('canvas/canvasMerged'); export const canvasMerged = createAction('canvas/canvasMerged');
export const stagingAreaImageSaved = createAction<{ imageName: string }>( export const stagingAreaImageSaved = createAction<{ imageDTO: ImageDTO }>(
'canvas/stagingAreaImageSaved' 'canvas/stagingAreaImageSaved'
); );

View File

@ -11,8 +11,8 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/thunks/image';
import { controlNetImageChanged } from '../store/controlNetSlice'; import { controlNetImageChanged } from '../store/controlNetSlice';
import { PostUploadAction } from 'services/api/types';
type Props = { type Props = {
controlNetId: string; controlNetId: string;

View File

@ -2,7 +2,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas'; import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas';
import { cloneDeep, forEach } from 'lodash-es'; import { cloneDeep, forEach } from 'lodash-es';
import { imageDeleted } from 'services/api/thunks/image'; import { imagesApi } from 'services/api/endpoints/images';
import { isAnySessionRejected } from 'services/api/thunks/session'; import { isAnySessionRejected } from 'services/api/thunks/session';
import { appSocketInvocationError } from 'services/events/actions'; import { appSocketInvocationError } from 'services/events/actions';
import { controlNetImageProcessed } from './actions'; import { controlNetImageProcessed } from './actions';
@ -300,10 +300,20 @@ export const controlNetSlice = createSlice({
} }
}); });
builder.addCase(imageDeleted.pending, (state, action) => { builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(
imagesApi.endpoints.deleteImage.matchFulfilled,
(state, action) => {
// Preemptively remove the image from all controlnets // Preemptively remove the image from all controlnets
// TODO: doesn't the imageusage stuff do this for us? // TODO: doesn't the imageusage stuff do this for us?
const { image_name } = action.meta.arg; const { image_name } = action.meta.arg.originalArgs;
forEach(state.controlNets, (c) => { forEach(state.controlNets, (c) => {
if (c.controlImage === image_name) { if (c.controlImage === image_name) {
c.controlImage = null; c.controlImage = null;
@ -313,15 +323,8 @@ export const controlNetSlice = createSlice({
c.processedControlImage = null; c.processedControlImage = null;
} }
}); });
}); }
);
builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
}, },
}); });

View File

@ -0,0 +1,50 @@
import {
ASSETS_CATEGORIES,
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaFileImage } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
categories: ASSETS_CATEGORIES,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const AllAssetsBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(boardIdSelected('assets'));
};
const { total } = useListImagesQuery(baseQueryArg, {
selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
});
// TODO: Do we support making 'images' 'assets? if yes, we need to handle this
// const droppableData: MoveBoardDropData = {
// id: 'all-images-board',
// actionType: 'MOVE_BOARD',
// context: { boardId: 'assets' },
// };
return (
<GenericBoard
onClick={handleClick}
isSelected={isSelected}
icon={FaFileImage}
label="All Assets"
badgeCount={total}
/>
);
};
export default AllAssetsBoard;

View File

@ -1,29 +1,48 @@
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import {
import { boardIdSelected } from 'features/gallery/store/gallerySlice'; IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaImages } from 'react-icons/fa'; import { FaImages } from 'react-icons/fa';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard'; import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
categories: IMAGE_CATEGORIES,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => { const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleAllImagesBoardClick = () => { const handleClick = () => {
dispatch(boardIdSelected('all')); dispatch(boardIdSelected('images'));
}; };
const droppableData: MoveBoardDropData = { const { total } = useListImagesQuery(baseQueryArg, {
id: 'all-images-board', selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
actionType: 'MOVE_BOARD', });
context: { boardId: null },
}; // TODO: Do we support making 'images' 'assets? if yes, we need to handle this
// const droppableData: MoveBoardDropData = {
// id: 'all-images-board',
// actionType: 'MOVE_BOARD',
// context: { boardId: 'images' },
// };
return ( return (
<GenericBoard <GenericBoard
droppableData={droppableData} onClick={handleClick}
onClick={handleAllImagesBoardClick}
isSelected={isSelected} isSelected={isSelected}
icon={FaImages} icon={FaImages}
label="All Images" label="All Images"
badgeCount={total}
/> />
); );
}; };

View File

@ -1,27 +1,27 @@
import { CloseIcon } from '@chakra-ui/icons';
import { import {
Collapse, Collapse,
Flex, Flex,
Grid, Grid,
GridItem, GridItem,
IconButton, useDisclosure,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
import AddBoardButton from './AddBoardButton'; import AddBoardButton from './AddBoardButton';
import AllAssetsBoard from './AllAssetsBoard';
import AllImagesBoard from './AllImagesBoard'; import AllImagesBoard from './AllImagesBoard';
import BatchBoard from './BatchBoard'; import BatchBoard from './BatchBoard';
import BoardsSearch from './BoardsSearch';
import GalleryBoard from './GalleryBoard'; import GalleryBoard from './GalleryBoard';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus'; import NoBoardBoard from './NoBoardBoard';
import DeleteBoardModal from '../DeleteBoardModal';
import { BoardDTO } from 'services/api/types';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
@ -39,31 +39,19 @@ type Props = {
const BoardsList = (props: Props) => { const BoardsList = (props: Props) => {
const { isOpen } = props; const { isOpen } = props;
const dispatch = useAppDispatch();
const { selectedBoardId, searchText } = useAppSelector(selector); const { selectedBoardId, searchText } = useAppSelector(selector);
const { data: boards } = useListAllBoardsQuery(); const { data: boards } = useListAllBoardsQuery();
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled; const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
const filteredBoards = searchText const filteredBoards = searchText
? boards?.filter((board) => ? boards?.filter((board) =>
board.board_name.toLowerCase().includes(searchText.toLowerCase()) board.board_name.toLowerCase().includes(searchText.toLowerCase())
) )
: boards; : boards;
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const [searchMode, setSearchMode] = useState(false); const [searchMode, setSearchMode] = useState(false);
const handleBoardSearch = (searchTerm: string) => {
setSearchMode(searchTerm.length > 0);
dispatch(setBoardSearchText(searchTerm));
};
const clearBoardSearch = () => {
setSearchMode(false);
dispatch(setBoardSearchText(''));
};
return ( return (
<>
<Collapse in={isOpen} animateOpacity> <Collapse in={isOpen} animateOpacity>
<Flex <Flex
layerStyle={'first'} layerStyle={'first'}
@ -76,26 +64,7 @@ const BoardsList = (props: Props) => {
}} }}
> >
<Flex sx={{ gap: 2, alignItems: 'center' }}> <Flex sx={{ gap: 2, alignItems: 'center' }}>
<InputGroup> <BoardsSearch setSearchMode={setSearchMode} />
<Input
placeholder="Search Boards..."
value={searchText}
onChange={(e) => {
handleBoardSearch(e.target.value);
}}
/>
{searchText && searchText.length && (
<InputRightElement>
<IconButton
onClick={clearBoardSearch}
size="xs"
variant="ghost"
aria-label="Clear Search"
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}
</InputGroup>
<AddBoardButton /> <AddBoardButton />
</Flex> </Flex>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
@ -121,7 +90,13 @@ const BoardsList = (props: Props) => {
{!searchMode && ( {!searchMode && (
<> <>
<GridItem sx={{ p: 1.5 }}> <GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={selectedBoardId === 'all'} /> <AllImagesBoard isSelected={selectedBoardId === 'images'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<AllAssetsBoard isSelected={selectedBoardId === 'assets'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<NoBoardBoard isSelected={selectedBoardId === 'no_board'} />
</GridItem> </GridItem>
{isBatchEnabled && ( {isBatchEnabled && (
<GridItem sx={{ p: 1.5 }}> <GridItem sx={{ p: 1.5 }}>
@ -136,6 +111,7 @@ const BoardsList = (props: Props) => {
<GalleryBoard <GalleryBoard
board={board} board={board}
isSelected={selectedBoardId === board.board_id} isSelected={selectedBoardId === board.board_id}
setBoardToDelete={setBoardToDelete}
/> />
</GridItem> </GridItem>
))} ))}
@ -143,6 +119,11 @@ const BoardsList = (props: Props) => {
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
</Flex> </Flex>
</Collapse> </Collapse>
<DeleteBoardModal
boardToDelete={boardToDelete}
setBoardToDelete={setBoardToDelete}
/>
</>
); );
}; };

View File

@ -0,0 +1,66 @@
import { CloseIcon } from '@chakra-ui/icons';
import {
IconButton,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { memo } from 'react';
const selector = createSelector(
[stateSelector],
({ boards }) => {
const { searchText } = boards;
return { searchText };
},
defaultSelectorOptions
);
type Props = {
setSearchMode: (searchMode: boolean) => void;
};
const BoardsSearch = (props: Props) => {
const { setSearchMode } = props;
const dispatch = useAppDispatch();
const { searchText } = useAppSelector(selector);
const handleBoardSearch = (searchTerm: string) => {
setSearchMode(searchTerm.length > 0);
dispatch(setBoardSearchText(searchTerm));
};
const clearBoardSearch = () => {
setSearchMode(false);
dispatch(setBoardSearchText(''));
};
return (
<InputGroup>
<Input
placeholder="Search Boards..."
value={searchText}
onChange={(e) => {
handleBoardSearch(e.target.value);
}}
/>
{searchText && searchText.length && (
<InputRightElement>
<IconButton
onClick={clearBoardSearch}
size="xs"
variant="ghost"
aria-label="Clear Search"
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}
</InputGroup>
);
};
export default memo(BoardsSearch);

View File

@ -8,35 +8,32 @@ import {
Image, Image,
MenuItem, MenuItem,
MenuList, MenuList,
Text,
useColorMode, useColorMode,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { FaFolder, FaImages, FaTrash } from 'react-icons/fa';
import {
useDeleteBoardMutation,
useUpdateBoardMutation,
} from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { skipToken } from '@reduxjs/toolkit/dist/query';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
// import { boardAddedToBatch } from 'app/store/middleware/listenerMiddleware/listeners/addBoardToBatch'; import { useAppDispatch } from 'app/store/storeHooks';
import { ContextMenu } from 'chakra-ui-contextmenu';
import IAIDroppable from 'common/components/IAIDroppable'; import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo } from 'react';
import { FaTrash, FaUser } from 'react-icons/fa';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu';
import { mode } from 'theme/util/mode'; import { mode } from 'theme/util/mode';
import { DeleteBoardImagesContext } from '../../../../../app/contexts/DeleteBoardImagesContext';
interface GalleryBoardProps { interface GalleryBoardProps {
board: BoardDTO; board: BoardDTO;
isSelected: boolean; isSelected: boolean;
setBoardToDelete: (board?: BoardDTO) => void;
} }
const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => { const GalleryBoard = memo(
({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { currentData: coverImage } = useGetImageDTOQuery( const { currentData: coverImage } = useGetImageDTOQuery(
@ -44,11 +41,7 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
); );
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const { board_name, board_id } = board; const { board_name, board_id } = board;
const { onClickDeleteBoardImages } = useContext(DeleteBoardImagesContext);
const handleSelectBoard = useCallback(() => { const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(board_id)); dispatch(boardIdSelected(board_id));
}, [board_id, dispatch]); }, [board_id, dispatch]);
@ -56,24 +49,13 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
const [updateBoard, { isLoading: isUpdateBoardLoading }] = const [updateBoard, { isLoading: isUpdateBoardLoading }] =
useUpdateBoardMutation(); useUpdateBoardMutation();
const [deleteBoard, { isLoading: isDeleteBoardLoading }] =
useDeleteBoardMutation();
const handleUpdateBoardName = (newBoardName: string) => { const handleUpdateBoardName = (newBoardName: string) => {
updateBoard({ board_id, changes: { board_name: newBoardName } }); updateBoard({ board_id, changes: { board_name: newBoardName } });
}; };
const handleDeleteBoard = useCallback(() => { const handleDeleteBoard = useCallback(() => {
deleteBoard(board_id); setBoardToDelete(board);
}, [board_id, deleteBoard]); }, [board, setBoardToDelete]);
const handleAddBoardToBatch = useCallback(() => {
// dispatch(boardAddedToBatch({ board_id }));
}, []);
const handleDeleteBoardAndImages = useCallback(() => {
onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]);
const droppableData: MoveBoardDropData = useMemo( const droppableData: MoveBoardDropData = useMemo(
() => ({ () => ({
@ -88,24 +70,24 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
<Box sx={{ touchAction: 'none', height: 'full' }}> <Box sx={{ touchAction: 'none', height: 'full' }}>
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }} menuProps={{ size: 'sm', isLazy: true }}
menuButtonProps={{
bg: 'transparent',
_hover: { bg: 'transparent' },
}}
renderMenu={() => ( renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}> <MenuList
sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps}
>
{board.image_count > 0 && ( {board.image_count > 0 && (
<> <>
<MenuItem {/* <MenuItem
isDisabled={!board.image_count} isDisabled={!board.image_count}
icon={<FaImages />} icon={<FaImages />}
onClickCapture={handleAddBoardToBatch} onClickCapture={handleAddBoardToBatch}
> >
Add Board to Batch Add Board to Batch
</MenuItem> </MenuItem> */}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
</> </>
)} )}
<MenuItem <MenuItem
@ -147,17 +129,19 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
flexShrink: 0, flexShrink: 0,
}} }}
> >
{board.cover_image_name && coverImage?.image_url && ( {board.cover_image_name && coverImage?.thumbnail_url && (
<Image src={coverImage?.image_url} draggable={false} /> <Image src={coverImage?.thumbnail_url} draggable={false} />
)} )}
{!(board.cover_image_name && coverImage?.image_url) && ( {!(board.cover_image_name && coverImage?.thumbnail_url) && (
<IAINoContentFallback <IAINoContentFallback
boxSize={8} boxSize={8}
icon={FaFolder} icon={FaUser}
sx={{ sx={{
border: '2px solid var(--invokeai-colors-base-200)', borderWidth: '2px',
borderStyle: 'solid',
borderColor: 'base.200',
_dark: { _dark: {
border: '2px solid var(--invokeai-colors-base-800)', borderColor: 'base.800',
}, },
}} }}
/> />
@ -172,7 +156,10 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
> >
<Badge variant="solid">{board.image_count}</Badge> <Badge variant="solid">{board.image_count}</Badge>
</Flex> </Flex>
<IAIDroppable data={droppableData} /> <IAIDroppable
data={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
/>
</Flex> </Flex>
<Flex <Flex
@ -189,6 +176,7 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
onSubmit={(nextValue) => { onSubmit={(nextValue) => {
handleUpdateBoardName(nextValue); handleUpdateBoardName(nextValue);
}} }}
sx={{ maxW: 'full' }}
> >
<EditablePreview <EditablePreview
sx={{ sx={{
@ -199,6 +187,8 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
fontSize: 'xs', fontSize: 'xs',
textAlign: 'center', textAlign: 'center',
p: 0, p: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}} }}
noOfLines={1} noOfLines={1}
/> />
@ -218,7 +208,8 @@ const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
</ContextMenu> </ContextMenu>
</Box> </Box>
); );
}); }
);
GalleryBoard.displayName = 'HoverableBoard'; GalleryBoard.displayName = 'HoverableBoard';

View File

@ -2,18 +2,34 @@ import { As, Badge, Flex } from '@chakra-ui/react';
import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable'; import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { ReactNode } from 'react';
type GenericBoardProps = { type GenericBoardProps = {
droppableData: TypesafeDroppableData; droppableData?: TypesafeDroppableData;
onClick: () => void; onClick: () => void;
isSelected: boolean; isSelected: boolean;
icon: As; icon: As;
label: string; label: string;
dropLabel?: ReactNode;
badgeCount?: number; badgeCount?: number;
}; };
const formatBadgeCount = (count: number) =>
Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(count);
const GenericBoard = (props: GenericBoardProps) => { const GenericBoard = (props: GenericBoardProps) => {
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props; const {
droppableData,
onClick,
isSelected,
icon,
label,
badgeCount,
dropLabel,
} = props;
return ( return (
<Flex <Flex
@ -59,10 +75,10 @@ const GenericBoard = (props: GenericBoardProps) => {
}} }}
> >
{badgeCount !== undefined && ( {badgeCount !== undefined && (
<Badge variant="solid">{badgeCount}</Badge> <Badge variant="solid">{formatBadgeCount(badgeCount)}</Badge>
)} )}
</Flex> </Flex>
<IAIDroppable data={droppableData} /> <IAIDroppable data={droppableData} dropLabel={dropLabel} />
</Flex> </Flex>
<Flex <Flex
sx={{ sx={{

View File

@ -0,0 +1,53 @@
import { Text } from '@chakra-ui/react';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import {
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaFolderOpen } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import {
ListImagesArgs,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GenericBoard from './GenericBoard';
const baseQueryArg: ListImagesArgs = {
board_id: 'none',
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const NoBoardBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(boardIdSelected('no_board'));
};
const { total } = useListImagesQuery(baseQueryArg, {
selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }),
});
// TODO: Do we support making 'images' 'assets? if yes, we need to handle this
const droppableData: MoveBoardDropData = {
id: 'all-images-board',
actionType: 'MOVE_BOARD',
context: { boardId: 'no_board' },
};
return (
<GenericBoard
droppableData={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
onClick={handleClick}
isSelected={isSelected}
icon={FaFolderOpen}
label="No Board"
badgeCount={total}
/>
);
};
export default NoBoardBoard;

View File

@ -1,114 +0,0 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Divider,
Flex,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import { memo, useContext, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
import { some } from 'lodash-es';
import { ImageUsage } from '../../../../app/contexts/DeleteImageContext';
const BoardImageInUseMessage = (props: { imagesUsage?: ImageUsage }) => {
const { imagesUsage } = props;
if (!imagesUsage) {
return null;
}
if (!some(imagesUsage)) {
return null;
}
return (
<>
<Text>
An image from this board is currently in use in the following features:
</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}>
{imagesUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imagesUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imagesUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imagesUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList>
<Text>
If you delete images from this board, those features will immediately be
reset.
</Text>
</>
);
};
const DeleteBoardImagesModal = () => {
const { t } = useTranslation();
const {
isOpen,
onClose,
board,
handleDeleteBoardImages,
handleDeleteBoardOnly,
imagesUsage,
} = useContext(DeleteBoardImagesContext);
const cancelRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
{board && (
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Board
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={3}>
<BoardImageInUseMessage imagesUsage={imagesUsage} />
<Divider />
<Text>{t('common.areYouSure')}</Text>
<Text fontWeight="bold">
This board has {board.image_count} image(s) that will be
deleted.
</Text>
</Flex>
</AlertDialogBody>
<AlertDialogFooter gap={3}>
<IAIButton ref={cancelRef} onClick={onClose}>
Cancel
</IAIButton>
<IAIButton
colorScheme="warning"
onClick={() => handleDeleteBoardOnly(board.board_id)}
>
Delete Board Only
</IAIButton>
<IAIButton
colorScheme="error"
onClick={() => handleDeleteBoardImages(board.board_id)}
>
Delete Board and Images
</IAIButton>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(DeleteBoardImagesModal);

View File

@ -0,0 +1,181 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Flex,
Skeleton,
Text,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import ImageUsageMessage from 'features/imageDeletion/components/ImageUsageMessage';
import {
ImageUsage,
getImageUsage,
} from 'features/imageDeletion/store/imageDeletionSlice';
import { some } from 'lodash-es';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
useDeleteBoardAndImagesMutation,
useDeleteBoardMutation,
useListAllImageNamesForBoardQuery,
} from 'services/api/endpoints/boards';
import { BoardDTO } from 'services/api/types';
type Props = {
boardToDelete?: BoardDTO;
setBoardToDelete: (board?: BoardDTO) => void;
};
const DeleteImageModal = (props: Props) => {
const { boardToDelete, setBoardToDelete } = props;
const { t } = useTranslation();
const canRestoreDeletedImagesFromBin = useAppSelector(
(state) => state.config.canRestoreDeletedImagesFromBin
);
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } =
useListAllImageNamesForBoardQuery(boardToDelete?.board_id ?? skipToken);
const selectImageUsageSummary = useMemo(
() =>
createSelector([stateSelector], (state) => {
const allImageUsage = (boardImageNames ?? []).map((imageName) =>
getImageUsage(state, imageName)
);
const imageUsageSummary: ImageUsage = {
isInitialImage: some(allImageUsage, (usage) => usage.isInitialImage),
isCanvasImage: some(allImageUsage, (usage) => usage.isCanvasImage),
isNodesImage: some(allImageUsage, (usage) => usage.isNodesImage),
isControlNetImage: some(
allImageUsage,
(usage) => usage.isControlNetImage
),
};
return { imageUsageSummary };
}),
[boardImageNames]
);
const [deleteBoardOnly, { isLoading: isDeleteBoardOnlyLoading }] =
useDeleteBoardMutation();
const [deleteBoardAndImages, { isLoading: isDeleteBoardAndImagesLoading }] =
useDeleteBoardAndImagesMutation();
const { imageUsageSummary } = useAppSelector(selectImageUsageSummary);
const handleDeleteBoardOnly = useCallback(() => {
if (!boardToDelete) {
return;
}
deleteBoardOnly(boardToDelete.board_id);
setBoardToDelete(undefined);
}, [boardToDelete, deleteBoardOnly, setBoardToDelete]);
const handleDeleteBoardAndImages = useCallback(() => {
if (!boardToDelete) {
return;
}
deleteBoardAndImages(boardToDelete.board_id);
setBoardToDelete(undefined);
}, [boardToDelete, deleteBoardAndImages, setBoardToDelete]);
const handleClose = useCallback(() => {
setBoardToDelete(undefined);
}, [setBoardToDelete]);
const cancelRef = useRef<HTMLButtonElement>(null);
const isLoading = useMemo(
() =>
isDeleteBoardAndImagesLoading ||
isDeleteBoardOnlyLoading ||
isFetchingBoardNames,
[
isDeleteBoardAndImagesLoading,
isDeleteBoardOnlyLoading,
isFetchingBoardNames,
]
);
if (!boardToDelete) {
return null;
}
return (
<AlertDialog
isOpen={Boolean(boardToDelete)}
onClose={handleClose}
leastDestructiveRef={cancelRef}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete {boardToDelete.board_name}
</AlertDialogHeader>
<AlertDialogBody>
<Flex direction="column" gap={3}>
{isFetchingBoardNames ? (
<Skeleton>
<Flex
sx={{
w: 'full',
h: 32,
}}
/>
</Skeleton>
) : (
<ImageUsageMessage
imageUsage={imageUsageSummary}
topMessage="This board contains images used in the following features:"
bottomMessage="Deleting this board and its images will reset any features currently using them."
/>
)}
<Text>Deleted boards cannot be restored.</Text>
<Text>
{canRestoreDeletedImagesFromBin
? t('gallery.deleteImageBin')
: t('gallery.deleteImagePermanent')}
</Text>
</Flex>
</AlertDialogBody>
<AlertDialogFooter>
<Flex
sx={{ justifyContent: 'space-between', width: 'full', gap: 2 }}
>
<IAIButton ref={cancelRef} onClick={handleClose}>
Cancel
</IAIButton>
<IAIButton
colorScheme="warning"
isLoading={isLoading}
onClick={handleDeleteBoardOnly}
>
Delete Board Only
</IAIButton>
<IAIButton
colorScheme="error"
isLoading={isLoading}
onClick={handleDeleteBoardAndImages}
>
Delete Board and Images
</IAIButton>
</Flex>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(DeleteImageModal);

View File

@ -17,6 +17,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer'; import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from '../NextPrevImageButtons'; import NextPrevImageButtons from '../NextPrevImageButtons';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { FaImage } from 'react-icons/fa';
export const imagesSelector = createSelector( export const imagesSelector = createSelector(
[stateSelector, selectLastSelectedImage], [stateSelector, selectLastSelectedImage],
@ -168,7 +170,11 @@ const CurrentImagePreview = () => {
draggableData={draggableData} draggableData={draggableData}
isUploadDisabled={true} isUploadDisabled={true}
fitContainer fitContainer
useThumbailFallback
dropLabel="Set as Current Image" dropLabel="Set as Current Image"
noContentFallback={
<IAINoContentFallback icon={FaImage} label="No image selected" />
}
/> />
)} )}
{shouldShowImageDetails && imageDTO && ( {shouldShowImageDetails && imageDTO && (

View File

@ -0,0 +1,91 @@
import { ChevronUpIcon } from '@chakra-ui/icons';
import { Button, Flex, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { memo } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
const selector = createSelector(
[stateSelector],
(state) => {
const { selectedBoardId } = state.gallery;
return {
selectedBoardId,
};
},
defaultSelectorOptions
);
type Props = {
isOpen: boolean;
onToggle: () => void;
};
const GalleryBoardName = (props: Props) => {
const { isOpen, onToggle } = props;
const { selectedBoardId } = useAppSelector(selector);
const { selectedBoardName } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => {
let selectedBoardName = '';
if (selectedBoardId === 'images') {
selectedBoardName = 'All Images';
} else if (selectedBoardId === 'assets') {
selectedBoardName = 'All Assets';
} else if (selectedBoardId === 'no_board') {
selectedBoardName = 'No Board';
} else if (selectedBoardId === 'batch') {
selectedBoardName = 'Batch';
} else {
const selectedBoard = data?.find((b) => b.board_id === selectedBoardId);
selectedBoardName = selectedBoard?.board_name || 'Unknown Board';
}
return { selectedBoardName };
},
});
return (
<Flex
as={Button}
onClick={onToggle}
size="sm"
variant="ghost"
sx={{
w: 'full',
justifyContent: 'center',
alignItems: 'center',
px: 2,
_hover: {
bg: 'base.100',
_dark: { bg: 'base.800' },
},
}}
>
<Text
noOfLines={1}
sx={{
w: 'full',
fontWeight: 600,
color: 'base.800',
_dark: {
color: 'base.200',
},
}}
>
{selectedBoardName}
</Text>
<ChevronUpIcon
sx={{
transform: isOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
);
};
export default memo(GalleryBoardName);

View File

@ -0,0 +1,44 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
const selector = createSelector(
[stateSelector],
(state) => {
const { shouldPinGallery } = state.ui;
return {
shouldPinGallery,
};
},
defaultSelectorOptions
);
const GalleryPinButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { shouldPinGallery } = useAppSelector(selector);
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
return (
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/>
);
};
export default GalleryPinButton;

View File

@ -0,0 +1,76 @@
import { Flex } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAISlider from 'common/components/IAISlider';
import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice';
import { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { FaWrench } from 'react-icons/fa';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
const selector = createSelector(
[stateSelector],
(state) => {
const { galleryImageMinimumWidth, shouldAutoSwitch } = state.gallery;
return {
galleryImageMinimumWidth,
shouldAutoSwitch,
};
},
defaultSelectorOptions
);
const GallerySettingsPopover = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { galleryImageMinimumWidth, shouldAutoSwitch } =
useAppSelector(selector);
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
return (
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<FaWrench />}
/>
}
>
<Flex direction="column" gap={2}>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
hideTooltip={true}
label={t('gallery.galleryImageSize')}
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
<IAISimpleCheckbox
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitch}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(shouldAutoSwitchChanged(e.target.checked))
}
/>
</Flex>
</IAIPopover>
);
};
export default GallerySettingsPopover;

View File

@ -1,13 +1,8 @@
import { MenuList } from '@chakra-ui/react'; import { MenuList } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback } from 'react';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu'; import { menuListMotionProps } from 'theme/components/menu';
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
import SingleSelectionMenuItems from './SingleSelectionMenuItems'; import SingleSelectionMenuItems from './SingleSelectionMenuItems';
type Props = { type Props = {
@ -16,23 +11,23 @@ type Props = {
}; };
const ImageContextMenu = ({ imageDTO, children }: Props) => { const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selector = useMemo( // const selector = useMemo(
() => // () =>
createSelector( // createSelector(
[stateSelector], // [stateSelector],
({ gallery }) => { // ({ gallery }) => {
const selectionCount = gallery.selection.length; // const selectionCount = gallery.selection.length;
return { selectionCount }; // return { selectionCount };
}, // },
defaultSelectorOptions // defaultSelectorOptions
), // ),
[] // []
); // );
const { selectionCount } = useAppSelector(selector); // const { selectionCount } = useAppSelector(selector);
const handleContextMenu = useCallback((e: MouseEvent<HTMLDivElement>) => { const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
}, []); }, []);
@ -48,13 +43,9 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
<MenuList <MenuList
sx={{ visibility: 'visible !important' }} sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps} motionProps={menuListMotionProps}
onContextMenu={handleContextMenu} onContextMenu={skipEvent}
> >
{selectionCount === 1 ? (
<SingleSelectionMenuItems imageDTO={imageDTO} /> <SingleSelectionMenuItems imageDTO={imageDTO} />
) : (
<MultipleSelectionMenuItems />
)}
</MenuList> </MenuList>
) : null ) : null
} }

View File

@ -28,8 +28,10 @@ import {
FaShare, FaShare,
FaTrash, FaTrash,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; import {
import { useGetImageMetadataQuery } from 'services/api/endpoints/images'; useGetImageMetadataQuery,
useRemoveImageFromBoardMutation,
} from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext'; import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
@ -128,15 +130,8 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
if (!imageDTO.board_id) { if (!imageDTO.board_id) {
return; return;
} }
removeFromBoard({ removeFromBoard({ imageDTO });
board_id: imageDTO.board_id, }, [imageDTO, removeFromBoard]);
image_name: imageDTO.image_name,
});
}, [imageDTO.board_id, imageDTO.image_name, removeFromBoard]);
const handleOpenInNewTab = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
}, [imageDTO.image_url]);
const handleAddToBatch = useCallback(() => { const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([imageDTO.image_name])); dispatch(imagesAddedToBatch([imageDTO.image_name]));
@ -149,10 +144,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
return ( return (
<> <>
<Link href={imageDTO.image_url} target="_blank"> <Link href={imageDTO.image_url} target="_blank">
<MenuItem <MenuItem icon={<FaExternalLinkAlt />}>
icon={<FaExternalLinkAlt />}
onClickCapture={handleOpenInNewTab}
>
{t('common.openInNewTab')} {t('common.openInNewTab')}
</MenuItem> </MenuItem>
</Link> </Link>
@ -161,6 +153,11 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.copyImage')} {t('parameters.copyImage')}
</MenuItem> </MenuItem>
)} )}
<Link download={true} href={imageDTO.image_url} target="_blank">
<MenuItem icon={<FaDownload />} w="100%">
{t('parameters.downloadImage')}
</MenuItem>
</Link>
<MenuItem <MenuItem
icon={<FaQuoteRight />} icon={<FaQuoteRight />}
onClickCapture={handleRecallPrompt} onClickCapture={handleRecallPrompt}
@ -219,11 +216,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
Remove from Board Remove from Board
</MenuItem> </MenuItem>
)} )}
<Link download={true} href={imageDTO.image_url} target="_blank">
<MenuItem icon={<FaDownload />} w="100%">
{t('parameters.downloadImage')}
</MenuItem>
</Link>
<MenuItem <MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }} sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />} icon={<FaTrash />}

View File

@ -1,113 +1,34 @@
import { import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react';
Box,
Button,
ButtonGroup,
Flex,
Text,
VStack,
useColorMode,
useDisclosure,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAISlider from 'common/components/IAISlider';
import {
setGalleryImageMinimumWidth,
setGalleryView,
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { ChangeEvent, memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaServer, FaWrench } from 'react-icons/fa';
import { ChevronUpIcon } from '@chakra-ui/icons';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { memo, useRef } from 'react';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode';
import BoardsList from './Boards/BoardsList/BoardsList'; import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
import GalleryPinButton from './GalleryPinButton';
import GallerySettingsPopover from './GallerySettingsPopover';
import BatchImageGrid from './ImageGrid/BatchImageGrid'; import BatchImageGrid from './ImageGrid/BatchImageGrid';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
(state) => { (state) => {
const { const { selectedBoardId } = state.gallery;
selectedBoardId,
galleryImageMinimumWidth,
galleryView,
shouldAutoSwitch,
} = state.gallery;
const { shouldPinGallery } = state.ui;
return { return {
selectedBoardId, selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
shouldAutoSwitch,
galleryView,
}; };
}, },
defaultSelectorOptions defaultSelectorOptions
); );
const ImageGalleryContent = () => { const ImageGalleryContent = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null); const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null); const galleryGridRef = useRef<HTMLDivElement>(null);
const { selectedBoardId } = useAppSelector(selector);
const { colorMode } = useColorMode(); const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } =
useDisclosure();
const {
selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
shouldAutoSwitch,
galleryView,
} = useAppSelector(selector);
const { selectedBoard } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => ({
selectedBoard: data?.find((b) => b.board_id === selectedBoardId),
}),
});
const boardTitle = useMemo(() => {
if (selectedBoardId === 'batch') {
return 'Batch';
}
if (selectedBoard) {
return selectedBoard.board_name;
}
return 'All Images';
}, [selectedBoard, selectedBoardId]);
const { isOpen: isBoardListOpen, onToggle } = useDisclosure();
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
const handleClickImagesCategory = useCallback(() => {
dispatch(setGalleryView('images'));
}, [dispatch]);
const handleClickAssetsCategory = useCallback(() => {
dispatch(setGalleryView('assets'));
}, [dispatch]);
return ( return (
<VStack <VStack
@ -127,95 +48,12 @@ const ImageGalleryContent = () => {
gap: 2, gap: 2,
}} }}
> >
<ButtonGroup isAttached> <GallerySettingsPopover />
<IAIIconButton <GalleryBoardName
tooltip={t('gallery.images')} isOpen={isBoardListOpen}
aria-label={t('gallery.images')} onToggle={onToggleBoardList}
onClick={handleClickImagesCategory}
isChecked={galleryView === 'images'}
size="sm"
icon={<FaImage />}
/>
<IAIIconButton
tooltip={t('gallery.assets')}
aria-label={t('gallery.assets')}
onClick={handleClickAssetsCategory}
isChecked={galleryView === 'assets'}
size="sm"
icon={<FaServer />}
/>
</ButtonGroup>
<Flex
as={Button}
onClick={onToggle}
size="sm"
variant="ghost"
sx={{
w: 'full',
justifyContent: 'center',
alignItems: 'center',
px: 2,
_hover: {
bg: mode('base.100', 'base.800')(colorMode),
},
}}
>
<Text
noOfLines={1}
sx={{
w: 'full',
color: mode('base.800', 'base.200')(colorMode),
fontWeight: 600,
}}
>
{boardTitle}
</Text>
<ChevronUpIcon
sx={{
transform: isBoardListOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
</Flex>
<IAIPopover
triggerComponent={
<IAIIconButton
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<FaWrench />}
/>
}
>
<Flex direction="column" gap={2}>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
hideTooltip={true}
label={t('gallery.galleryImageSize')}
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
/>
<IAISimpleCheckbox
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitch}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(shouldAutoSwitchChanged(e.target.checked))
}
/>
</Flex>
</IAIPopover>
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/> />
<GalleryPinButton />
</Flex> </Flex>
<Box> <Box>
<BoardsList isOpen={isBoardListOpen} /> <BoardsList isOpen={isBoardListOpen} />

View File

@ -1,16 +1,13 @@
import { Box, Spinner } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { import { imageSelected } from 'features/gallery/store/gallerySlice';
imageRangeEndSelected,
imageSelected,
imageSelectionToggled,
} from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice'; import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react'; import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@ -84,7 +81,7 @@ const GalleryImage = (props: HoverableImageProps) => {
}, [imageDTO, selection, selectionCount]); }, [imageDTO, selection, selectionCount]);
if (!imageDTO) { if (!imageDTO) {
return <Spinner />; return <IAIFillSkeleton />;
} }
return ( return (

View File

@ -1,58 +1,26 @@
import { Box } from '@chakra-ui/react'; import { Box, Spinner } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton'; import IAIButton from 'common/components/IAIButton';
import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { IMAGE_LIMIT } from 'features/gallery//store/gallerySlice';
import {
UseOverlayScrollbarsParams,
useOverlayScrollbars,
} from 'overlayscrollbars-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa'; import { FaExclamationCircle, FaImage } from 'react-icons/fa';
import GalleryImage from './GalleryImage';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
IMAGE_LIMIT,
} from 'features/gallery//store/gallerySlice';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { VirtuosoGrid } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso';
import { receivedPageOfImages } from 'services/api/thunks/image'; import {
import { useListBoardImagesQuery } from '../../../../services/api/endpoints/boardImages'; useLazyListImagesQuery,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GalleryImage from './GalleryImage';
import ImageGridItemContainer from './ImageGridItemContainer'; import ImageGridItemContainer from './ImageGridItemContainer';
import ImageGridListContainer from './ImageGridListContainer'; import ImageGridListContainer from './ImageGridListContainer';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
const selector = createSelector( const overlayScrollbarsConfig: UseOverlayScrollbarsParams = {
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const {
galleryImageMinimumWidth,
selectedBoardId,
galleryView,
total,
isLoading,
} = state.gallery;
return {
imageNames: filteredImages.map((i) => i.image_name),
total,
selectedBoardId,
galleryView,
galleryImageMinimumWidth,
isLoading,
};
},
defaultSelectorOptions
);
const GalleryImageGrid = () => {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const emptyGalleryRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true, defer: true,
options: { options: {
scrollbars: { scrollbars: {
@ -63,62 +31,40 @@ const GalleryImageGrid = () => {
}, },
overflow: { x: 'hidden' }, overflow: { x: 'hidden' },
}, },
}); };
const [didInitialFetch, setDidInitialFetch] = useState(false); const GalleryImageGrid = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const rootRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const { const [initialize, osInstance] = useOverlayScrollbars(
galleryImageMinimumWidth, overlayScrollbarsConfig
imageNames: imageNamesAll, //all images names loaded on main tab,
total: totalAll,
selectedBoardId,
galleryView,
isLoading: isLoadingAll,
} = useAppSelector(selector);
const { data: imagesForBoard, isLoading: isLoadingImagesForBoard } =
useListBoardImagesQuery(
{ board_id: selectedBoardId },
{ skip: selectedBoardId === 'all' }
); );
const imageNames = useMemo(() => { const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
if (selectedBoardId === 'all') {
return imageNamesAll; // already sorted by images/uploads in gallery selector const { currentData, isFetching, isSuccess, isError } =
} else { useListImagesQuery(queryArgs);
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; const [listImages] = useLazyListImagesQuery();
const imageList = (imagesForBoard?.items || []).filter((img) =>
categories.includes(img.image_category)
);
return imageList.map((img) => img.image_name);
}
}, [selectedBoardId, galleryView, imagesForBoard, imageNamesAll]);
const areMoreAvailable = useMemo(() => { const areMoreAvailable = useMemo(() => {
return selectedBoardId === 'all' ? totalAll > imageNamesAll.length : false; if (!currentData) {
}, [selectedBoardId, imageNamesAll.length, totalAll]); return false;
}
const isLoading = useMemo(() => { return currentData.ids.length < currentData.total;
return selectedBoardId === 'all' ? isLoadingAll : isLoadingImagesForBoard; }, [currentData]);
}, [selectedBoardId, isLoadingAll, isLoadingImagesForBoard]);
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch( listImages({
receivedPageOfImages({ ...queryArgs,
categories: offset: currentData?.ids.length ?? 0,
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
is_intermediate: false,
offset: imageNames.length,
limit: IMAGE_LIMIT, limit: IMAGE_LIMIT,
}) });
); }, [listImages, queryArgs, currentData?.ids.length]);
}, [dispatch, imageNames.length, galleryView]);
useEffect(() => { useEffect(() => {
// Set up gallery scroler // Initialize the gallery's custom scrollbar
const { current: root } = rootRef; const { current: root } = rootRef;
if (scroller && root) { if (scroller && root) {
initialize({ initialize({
@ -131,47 +77,17 @@ const GalleryImageGrid = () => {
return () => osInstance()?.destroy(); return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]); }, [scroller, initialize, osInstance]);
const handleEndReached = useMemo(() => { if (!currentData) {
if (areMoreAvailable) {
return handleLoadMoreImages;
}
return undefined;
}, [areMoreAvailable, handleLoadMoreImages]);
// useEffect(() => {
// if (!didInitialFetch) {
// return;
// }
// // rough, conservative calculation of how many images fit in the gallery
// // TODO: this gets an incorrect value on first load...
// const galleryHeight = rootRef.current?.clientHeight ?? 0;
// const galleryWidth = rootRef.current?.clientHeight ?? 0;
// const rows = galleryHeight / galleryImageMinimumWidth;
// const columns = galleryWidth / galleryImageMinimumWidth;
// const imagesToLoad = Math.ceil(rows * columns);
// setDidInitialFetch(true);
// // load up that many images
// dispatch(
// receivedPageOfImages({
// offset: 0,
// limit: 10,
// })
// );
// }, [
// didInitialFetch,
// dispatch,
// galleryImageMinimumWidth,
// galleryView,
// selectedBoardId,
// ]);
if (!isLoading && imageNames.length === 0) {
return ( return (
<Box ref={emptyGalleryRef} sx={{ w: 'full', h: 'full' }}> <Box sx={{ w: 'full', h: 'full' }}>
<Spinner size="2xl" opacity={0.5} />
</Box>
);
}
if (isSuccess && currentData?.ids.length === 0) {
return (
<Box sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback <IAINoContentFallback
label={t('gallery.noImagesInGallery')} label={t('gallery.noImagesInGallery')}
icon={FaImage} icon={FaImage}
@ -180,27 +96,28 @@ const GalleryImageGrid = () => {
); );
} }
if (status !== 'rejected') { if (isSuccess && currentData) {
return ( return (
<> <>
<Box ref={rootRef} data-overlayscrollbars="" h="100%"> <Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid <VirtuosoGrid
style={{ height: '100%' }} style={{ height: '100%' }}
data={imageNames} data={currentData.ids}
endReached={handleLoadMoreImages}
components={{ components={{
Item: ImageGridItemContainer, Item: ImageGridItemContainer,
List: ImageGridListContainer, List: ImageGridListContainer,
}} }}
scrollerRef={setScroller} scrollerRef={setScroller}
itemContent={(index, imageName) => ( itemContent={(index, imageName) => (
<GalleryImage key={imageName} imageName={imageName} /> <GalleryImage key={imageName} imageName={imageName as string} />
)} )}
/> />
</Box> </Box>
<IAIButton <IAIButton
onClick={handleLoadMoreImages} onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable} isDisabled={!areMoreAvailable}
isLoading={status === 'pending'} isLoading={isFetching}
loadingText="Loading" loadingText="Loading"
flexShrink={0} flexShrink={0}
> >
@ -211,6 +128,17 @@ const GalleryImageGrid = () => {
</> </>
); );
} }
if (isError) {
return (
<Box sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback
label="Unable to load Gallery"
icon={FaExclamationCircle}
/>
</Box>
);
}
}; };
export default memo(GalleryImageGrid); export default memo(GalleryImageGrid);

View File

@ -11,11 +11,9 @@ const ImageMetadataActions = (props: Props) => {
const { metadata } = props; const { metadata } = props;
const { const {
recallBothPrompts,
recallPositivePrompt, recallPositivePrompt,
recallNegativePrompt, recallNegativePrompt,
recallSeed, recallSeed,
recallInitialImage,
recallCfgScale, recallCfgScale,
recallModel, recallModel,
recallScheduler, recallScheduler,
@ -23,7 +21,6 @@ const ImageMetadataActions = (props: Props) => {
recallWidth, recallWidth,
recallHeight, recallHeight,
recallStrength, recallStrength,
recallAllParameters,
} = useRecallParameters(); } = useRecallParameters();
const handleRecallPositivePrompt = useCallback(() => { const handleRecallPositivePrompt = useCallback(() => {

View File

@ -2,61 +2,76 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
IMAGE_LIMIT,
imageSelected, imageSelected,
selectImagesById, selectImagesById,
} from 'features/gallery/store/gallerySlice'; } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { receivedPageOfImages } from 'services/api/thunks/image'; import {
import { selectFilteredImages } from '../store/gallerySelectors'; ListImagesArgs,
imagesAdapter,
imagesApi,
useLazyListImagesQuery,
} from 'services/api/endpoints/images';
import { selectListImagesBaseQueryArgs } from '../store/gallerySelectors';
export const nextPrevImageButtonsSelector = createSelector( export const nextPrevImageButtonsSelector = createSelector(
[stateSelector, selectFilteredImages], [stateSelector, selectListImagesBaseQueryArgs],
(state, filteredImages) => { (state, baseQueryArgs) => {
const { total, isFetching } = state.gallery; const { data, status } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const lastSelectedImage = const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1]; state.gallery.selection[state.gallery.selection.length - 1];
if (!lastSelectedImage || filteredImages.length === 0) { const isFetching = status === 'pending';
if (!data || !lastSelectedImage || data.total === 0) {
return { return {
isFetching,
queryArgs: baseQueryArgs,
isOnFirstImage: true, isOnFirstImage: true,
isOnLastImage: true, isOnLastImage: true,
}; };
} }
const currentImageIndex = filteredImages.findIndex( const queryArgs: ListImagesArgs = {
...baseQueryArgs,
offset: data.ids.length,
limit: IMAGE_LIMIT,
};
const selectors = imagesAdapter.getSelectors();
const images = selectors.selectAll(data);
const currentImageIndex = images.findIndex(
(i) => i.image_name === lastSelectedImage (i) => i.image_name === lastSelectedImage
); );
const nextImageIndex = clamp( const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
currentImageIndex + 1,
0,
filteredImages.length - 1
);
const prevImageIndex = clamp( const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
currentImageIndex - 1,
0,
filteredImages.length - 1
);
const nextImageId = filteredImages[nextImageIndex].image_name; const nextImageId = images[nextImageIndex].image_name;
const prevImageId = filteredImages[prevImageIndex].image_name; const prevImageId = images[prevImageIndex].image_name;
const nextImage = selectImagesById(state, nextImageId); const nextImage = selectors.selectById(data, nextImageId);
const prevImage = selectImagesById(state, prevImageId); const prevImage = selectors.selectById(data, prevImageId);
const imagesLength = filteredImages.length; const imagesLength = images.length;
return { return {
isOnFirstImage: currentImageIndex === 0, isOnFirstImage: currentImageIndex === 0,
isOnLastImage: isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
areMoreImagesAvailable: total > imagesLength, areMoreImagesAvailable: data?.total ?? 0 > imagesLength,
isFetching, isFetching: status === 'pending',
nextImage, nextImage,
prevImage, prevImage,
nextImageId, nextImageId,
prevImageId, prevImageId,
queryArgs,
}; };
}, },
{ {
@ -76,6 +91,7 @@ export const useNextPrevImage = () => {
prevImageId, prevImageId,
areMoreImagesAvailable, areMoreImagesAvailable,
isFetching, isFetching,
queryArgs,
} = useAppSelector(nextPrevImageButtonsSelector); } = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => { const handlePrevImage = useCallback(() => {
@ -86,13 +102,11 @@ export const useNextPrevImage = () => {
nextImageId && dispatch(imageSelected(nextImageId)); nextImageId && dispatch(imageSelected(nextImageId));
}, [dispatch, nextImageId]); }, [dispatch, nextImageId]);
const [listImages] = useLazyListImagesQuery();
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
dispatch( listImages(queryArgs);
receivedPageOfImages({ }, [listImages, queryArgs]);
is_intermediate: false,
})
);
}, [dispatch]);
return { return {
handlePrevImage, handlePrevImage,

View File

@ -1,136 +1,38 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { clamp, keyBy } from 'lodash-es'; import { ListImagesArgs } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { INITIAL_IMAGE_LIMIT } from './gallerySlice';
import { import {
ASSETS_CATEGORIES, getBoardIdQueryParamForBoard,
BoardId, getCategoriesQueryParamForBoard,
IMAGE_CATEGORIES, } from './util';
imagesAdapter,
initialGalleryState,
} from './gallerySlice';
export const gallerySelector = (state: RootState) => state.gallery; export const gallerySelector = (state: RootState) => state.gallery;
const isInSelectedBoard = (
selectedBoardId: BoardId,
imageDTO: ImageDTO,
batchImageNames: string[]
) => {
if (selectedBoardId === 'all') {
// all images are in the "All Images" board
return true;
}
if (selectedBoardId === 'none' && !imageDTO.board_id) {
// Only images without a board are in the "No Board" board
return true;
}
if (
selectedBoardId === 'batch' &&
batchImageNames.includes(imageDTO.image_name)
) {
// Only images with is_batch are in the "Batch" board
return true;
}
return selectedBoardId === imageDTO.board_id;
};
export const selectFilteredImagesLocal = createSelector(
[(state: typeof initialGalleryState) => state],
(galleryState) => {
const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
const { galleryView, selectedBoardId } = galleryState;
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInBoard = isInSelectedBoard(
selectedBoardId,
i,
galleryState.batchImageNames
);
return isInCategory && isInBoard;
});
return filteredImages;
}
);
export const selectFilteredImages = createSelector(
(state: RootState) => state,
(state) => {
return selectFilteredImagesLocal(state.gallery);
},
defaultSelectorOptions
);
export const selectFilteredImagesAsObject = createSelector(
selectFilteredImages,
(filteredImages) => keyBy(filteredImages, 'image_name')
);
export const selectFilteredImagesIds = createSelector(
selectFilteredImages,
(filteredImages) => filteredImages.map((i) => i.image_name)
);
export const selectLastSelectedImage = createSelector( export const selectLastSelectedImage = createSelector(
(state: RootState) => state, (state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1], (state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions defaultSelectorOptions
); );
export const selectSelectedImages = createSelector( export const selectListImagesBaseQueryArgs = createSelector(
(state: RootState) => state, [(state: RootState) => state],
(state) => (state) => {
imagesAdapter const { selectedBoardId } = state.gallery;
.getSelectors()
.selectAll(state.gallery)
.filter((i) => state.gallery.selection.includes(i.image_name)),
defaultSelectorOptions
);
export const selectNextImageToSelectLocal = createSelector( const categories = getCategoriesQueryParamForBoard(selectedBoardId);
[ const board_id = getBoardIdQueryParamForBoard(selectedBoardId);
(state: typeof initialGalleryState) => state,
(state: typeof initialGalleryState, image_name: string) => image_name,
],
(state, image_name) => {
const filteredImages = selectFilteredImagesLocal(state);
const ids = filteredImages.map((i) => i.image_name);
const deletedImageIndex = ids.findIndex( const listImagesBaseQueryArgs: ListImagesArgs = {
(result) => result.toString() === image_name categories,
); board_id,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const filteredIds = ids.filter((id) => id.toString() !== image_name); return listImagesBaseQueryArgs;
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
return newSelectedImageId;
}
);
export const selectNextImageToSelect = createSelector(
[
(state: RootState) => state,
(state: RootState, image_name: string) => image_name,
],
(state, image_name) => {
return selectNextImageToSelectLocal(state.gallery, image_name);
}, },
defaultSelectorOptions defaultSelectorOptions
); );

View File

@ -1,20 +1,8 @@
import type { PayloadAction, Update } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { dateComparator } from 'common/util/dateComparator';
import { uniq } from 'lodash-es'; import { uniq } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards'; import { boardsApi } from 'services/api/endpoints/boards';
import { import { ImageCategory } from 'services/api/types';
imageUrlsReceived,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { ImageCategory, ImageDTO } from 'services/api/types';
import { selectFilteredImagesLocal } from './gallerySelectors';
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = [ export const ASSETS_CATEGORIES: ImageCategory[] = [
@ -26,113 +14,74 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
export const INITIAL_IMAGE_LIMIT = 100; export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20; export const IMAGE_LIMIT = 20;
export type GalleryView = 'images' | 'assets'; // export type GalleryView = 'images' | 'assets';
export type BoardId = export type BoardId =
| 'all' | 'images'
| 'none' | 'assets'
| 'no_board'
| 'batch' | 'batch'
| (string & Record<never, never>); | (string & Record<never, never>);
type AdditionaGalleryState = { type GalleryState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
isFetching: boolean;
selection: string[]; selection: string[];
shouldAutoSwitch: boolean; shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number; galleryImageMinimumWidth: number;
galleryView: GalleryView;
selectedBoardId: BoardId; selectedBoardId: BoardId;
isInitialized: boolean;
batchImageNames: string[]; batchImageNames: string[];
isBatchEnabled: boolean; isBatchEnabled: boolean;
}; };
export const initialGalleryState = export const initialGalleryState: GalleryState = {
imagesAdapter.getInitialState<AdditionaGalleryState>({
offset: 0,
limit: 0,
total: 0,
isLoading: true,
isFetching: true,
selection: [], selection: [],
shouldAutoSwitch: true, shouldAutoSwitch: true,
galleryImageMinimumWidth: 96, galleryImageMinimumWidth: 96,
galleryView: 'images', selectedBoardId: 'images',
selectedBoardId: 'all',
isInitialized: false,
batchImageNames: [], batchImageNames: [],
isBatchEnabled: false, isBatchEnabled: false,
}); };
export const gallerySlice = createSlice({ export const gallerySlice = createSlice({
name: 'gallery', name: 'gallery',
initialState: initialGalleryState, initialState: initialGalleryState,
reducers: { reducers: {
imageUpserted: (state, action: PayloadAction<ImageDTO>) => {
imagesAdapter.upsertOne(state, action.payload);
if (
state.shouldAutoSwitch &&
action.payload.image_category === 'general'
) {
state.selection = [action.payload.image_name];
state.galleryView = 'images';
state.selectedBoardId = 'all';
}
},
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
imagesAdapter.updateOne(state, action.payload);
},
imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload);
state.batchImageNames = state.batchImageNames.filter(
(name) => name !== action.payload
);
},
imagesRemoved: (state, action: PayloadAction<string[]>) => { imagesRemoved: (state, action: PayloadAction<string[]>) => {
imagesAdapter.removeMany(state, action.payload); // TODO: port all instances of this to use RTK Query cache
state.batchImageNames = state.batchImageNames.filter( // imagesAdapter.removeMany(state, action.payload);
(name) => !action.payload.includes(name) // state.batchImageNames = state.batchImageNames.filter(
); // (name) => !action.payload.includes(name)
// );
}, },
imageRangeEndSelected: (state, action: PayloadAction<string>) => { imageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload; // const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1]; // const lastSelectedImage = state.selection[state.selection.length - 1];
// const filteredImages = selectFilteredImagesLocal(state);
const filteredImages = selectFilteredImagesLocal(state); // const lastClickedIndex = filteredImages.findIndex(
// (n) => n.image_name === lastSelectedImage
const lastClickedIndex = filteredImages.findIndex( // );
(n) => n.image_name === lastSelectedImage // const currentClickedIndex = filteredImages.findIndex(
); // (n) => n.image_name === rangeEndImageName
// );
const currentClickedIndex = filteredImages.findIndex( // if (lastClickedIndex > -1 && currentClickedIndex > -1) {
(n) => n.image_name === rangeEndImageName // // We have a valid range!
); // const start = Math.min(lastClickedIndex, currentClickedIndex);
// const end = Math.max(lastClickedIndex, currentClickedIndex);
if (lastClickedIndex > -1 && currentClickedIndex > -1) { // const imagesToSelect = filteredImages
// We have a valid range! // .slice(start, end + 1)
const start = Math.min(lastClickedIndex, currentClickedIndex); // .map((i) => i.image_name);
const end = Math.max(lastClickedIndex, currentClickedIndex); // state.selection = uniq(state.selection.concat(imagesToSelect));
// }
const imagesToSelect = filteredImages
.slice(start, end + 1)
.map((i) => i.image_name);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
}, },
imageSelectionToggled: (state, action: PayloadAction<string>) => { imageSelectionToggled: (state, action: PayloadAction<string>) => {
if ( // if (
state.selection.includes(action.payload) && // state.selection.includes(action.payload) &&
state.selection.length > 1 // state.selection.length > 1
) { // ) {
state.selection = state.selection.filter( // state.selection = state.selection.filter(
(imageName) => imageName !== action.payload // (imageName) => imageName !== action.payload
); // );
} else { // } else {
state.selection = uniq(state.selection.concat(action.payload)); // state.selection = uniq(state.selection.concat(action.payload));
} // }
}, },
imageSelected: (state, action: PayloadAction<string | null>) => { imageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload ? [action.payload] : []; state.selection = action.payload ? [action.payload] : [];
@ -143,15 +92,9 @@ export const gallerySlice = createSlice({
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => { setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload; state.galleryImageMinimumWidth = action.payload;
}, },
setGalleryView: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
boardIdSelected: (state, action: PayloadAction<BoardId>) => { boardIdSelected: (state, action: PayloadAction<BoardId>) => {
state.selectedBoardId = action.payload; state.selectedBoardId = action.payload;
}, },
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => { isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isBatchEnabled = action.payload; state.isBatchEnabled = action.payload;
}, },
@ -182,47 +125,11 @@ export const gallerySlice = createSlice({
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(receivedPageOfImages.pending, (state) => {
state.isFetching = true;
});
builder.addCase(receivedPageOfImages.rejected, (state) => {
state.isFetching = false;
});
builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
state.isFetching = false;
const { board_id, categories, image_origin, is_intermediate } =
action.meta.arg;
const { items, offset, limit, total } = action.payload;
imagesAdapter.upsertMany(state, items);
if (state.selection.length === 0 && items.length) {
state.selection = [items[0].image_name];
}
if (!categories?.includes('general') || board_id) {
// need to skip updating the total images count if the images recieved were for a specific board
// TODO: this doesn't work when on the Asset tab/category...
return;
}
state.offset = offset;
state.total = total;
});
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
const { image_name, image_url, thumbnail_url } = action.payload;
imagesAdapter.updateOne(state, {
id: image_name,
changes: { image_url, thumbnail_url },
});
});
builder.addMatcher( builder.addMatcher(
boardsApi.endpoints.deleteBoard.matchFulfilled, boardsApi.endpoints.deleteBoard.matchFulfilled,
(state, action) => { (state, action) => {
if (action.meta.arg.originalArgs === state.selectedBoardId) { if (action.meta.arg.originalArgs === state.selectedBoardId) {
state.selectedBoardId = 'all'; state.selectedBoardId = 'images';
} }
} }
); );
@ -230,26 +137,13 @@ export const gallerySlice = createSlice({
}); });
export const { export const {
selectAll: selectImagesAll,
selectById: selectImagesById,
selectEntities: selectImagesEntities,
selectIds: selectImagesIds,
selectTotal: selectImagesTotal,
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery);
export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imagesRemoved, imagesRemoved,
imageRangeEndSelected, imageRangeEndSelected,
imageSelectionToggled, imageSelectionToggled,
imageSelected, imageSelected,
shouldAutoSwitchChanged, shouldAutoSwitchChanged,
setGalleryImageMinimumWidth, setGalleryImageMinimumWidth,
setGalleryView,
boardIdSelected, boardIdSelected,
isLoadingChanged,
isBatchEnabledChanged, isBatchEnabledChanged,
imagesAddedToBatch, imagesAddedToBatch,
imagesRemovedFromBatch, imagesRemovedFromBatch,

View File

@ -0,0 +1,54 @@
import { SYSTEM_BOARDS } from 'services/api/endpoints/images';
import { ASSETS_CATEGORIES, BoardId, IMAGE_CATEGORIES } from './gallerySlice';
import { ImageCategory } from 'services/api/types';
import { isEqual } from 'lodash-es';
export const getCategoriesQueryParamForBoard = (
board_id: BoardId
): ImageCategory[] | undefined => {
if (board_id === 'assets') {
return ASSETS_CATEGORIES;
}
if (board_id === 'images') {
return IMAGE_CATEGORIES;
}
// 'no_board' board, 'batch' board, user boards
return undefined;
};
export const getBoardIdQueryParamForBoard = (
board_id: BoardId
): string | undefined => {
if (board_id === 'no_board') {
return 'none';
}
// system boards besides 'no_board'
if (SYSTEM_BOARDS.includes(board_id)) {
return undefined;
}
// user boards
return board_id;
};
export const getBoardIdFromBoardAndCategoriesQueryParam = (
board_id: string | undefined,
categories: ImageCategory[] | undefined
): BoardId => {
if (board_id === undefined && isEqual(categories, IMAGE_CATEGORIES)) {
return 'images';
}
if (board_id === undefined && isEqual(categories, ASSETS_CATEGORIES)) {
return 'assets';
}
if (board_id === 'none') {
return 'no_board';
}
return board_id ?? 'UNKNOWN_BOARD';
};

View File

@ -2,9 +2,17 @@ import { some } from 'lodash-es';
import { memo } from 'react'; import { memo } from 'react';
import { ImageUsage } from '../store/imageDeletionSlice'; import { ImageUsage } from '../store/imageDeletionSlice';
import { ListItem, Text, UnorderedList } from '@chakra-ui/react'; import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
type Props = {
const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => { imageUsage?: ImageUsage;
const { imageUsage } = props; topMessage?: string;
bottomMessage?: string;
};
const ImageUsageMessage = (props: Props) => {
const {
imageUsage,
topMessage = 'This image is currently in use in the following features:',
bottomMessage = 'If you delete this image, those features will immediately be reset.',
} = props;
if (!imageUsage) { if (!imageUsage) {
return null; return null;
@ -16,16 +24,14 @@ const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
return ( return (
<> <>
<Text>This image is currently in use in the following features:</Text> <Text>{topMessage}</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}> <UnorderedList sx={{ paddingInlineStart: 6 }}>
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>} {imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>} {imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>} {imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>} {imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList> </UnorderedList>
<Text> <Text>{bottomMessage}</Text>
If you delete this image, those features will immediately be reset.
</Text>
</> </>
); );
}; };

View File

@ -51,17 +51,8 @@ export type ImageUsage = {
isControlNetImage: boolean; isControlNetImage: boolean;
}; };
export const selectImageUsage = createSelector( export const getImageUsage = (state: RootState, image_name: string) => {
[(state: RootState) => state], const { generation, canvas, nodes, controlNet } = state;
({ imageDeletion, generation, canvas, nodes, controlNet }) => {
const { imageToDelete } = imageDeletion;
if (!imageToDelete) {
return;
}
const { image_name } = imageToDelete;
const isInitialImage = generation.initialImage?.imageName === image_name; const isInitialImage = generation.initialImage?.imageName === image_name;
const isCanvasImage = canvas.layerState.objects.some( const isCanvasImage = canvas.layerState.objects.some(
@ -89,6 +80,22 @@ export const selectImageUsage = createSelector(
isControlNetImage, isControlNetImage,
}; };
return imageUsage;
};
export const selectImageUsage = createSelector(
[(state: RootState) => state],
(state) => {
const { imageToDelete } = state.imageDeletion;
if (!imageToDelete) {
return;
}
const { image_name } = imageToDelete;
const imageUsage = getImageUsage(state, image_name);
return imageUsage; return imageUsage;
}, },
defaultSelectorOptions defaultSelectorOptions

View File

@ -15,8 +15,8 @@ import {
} from 'app/components/ImageDnd/typesafeDnd'; } from 'app/components/ImageDnd/typesafeDnd';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/thunks/image';
import { FieldComponentProps } from './types'; import { FieldComponentProps } from './types';
import { PostUploadAction } from 'services/api/types';
const ImageInputFieldComponent = ( const ImageInputFieldComponent = (
props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate> props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate>

View File

@ -29,6 +29,7 @@ export const addControlNetToLinearGraph = (
const controlNetIterateNode: CollectInvocation = { const controlNetIterateNode: CollectInvocation = {
id: CONTROL_NET_COLLECT, id: CONTROL_NET_COLLECT,
type: 'collect', type: 'collect',
is_intermediate: true,
}; };
graph.nodes[CONTROL_NET_COLLECT] = controlNetIterateNode; graph.nodes[CONTROL_NET_COLLECT] = controlNetIterateNode;
graph.edges.push({ graph.edges.push({
@ -55,6 +56,7 @@ export const addControlNetToLinearGraph = (
const controlNetNode: ControlNetInvocation = { const controlNetNode: ControlNetInvocation = {
id: `control_net_${controlNetId}`, id: `control_net_${controlNetId}`,
type: 'controlnet', type: 'controlnet',
is_intermediate: true,
begin_step_percent: beginStepPct, begin_step_percent: beginStepPct,
end_step_percent: endStepPct, end_step_percent: endStepPct,
control_mode: controlMode, control_mode: controlMode,

View File

@ -43,6 +43,7 @@ export const addDynamicPromptsToGraph = (
const dynamicPromptNode: DynamicPromptInvocation = { const dynamicPromptNode: DynamicPromptInvocation = {
id: DYNAMIC_PROMPT, id: DYNAMIC_PROMPT,
type: 'dynamic_prompt', type: 'dynamic_prompt',
is_intermediate: true,
max_prompts: combinatorial ? maxPrompts : iterations, max_prompts: combinatorial ? maxPrompts : iterations,
combinatorial, combinatorial,
prompt: positivePrompt, prompt: positivePrompt,
@ -51,6 +52,7 @@ export const addDynamicPromptsToGraph = (
const iterateNode: IterateInvocation = { const iterateNode: IterateInvocation = {
id: ITERATE, id: ITERATE,
type: 'iterate', type: 'iterate',
is_intermediate: true,
}; };
graph.nodes[DYNAMIC_PROMPT] = dynamicPromptNode; graph.nodes[DYNAMIC_PROMPT] = dynamicPromptNode;
@ -99,6 +101,7 @@ export const addDynamicPromptsToGraph = (
const randomIntNode: RandomIntInvocation = { const randomIntNode: RandomIntInvocation = {
id: RANDOM_INT, id: RANDOM_INT,
type: 'rand_int', type: 'rand_int',
is_intermediate: true,
}; };
graph.nodes[RANDOM_INT] = randomIntNode; graph.nodes[RANDOM_INT] = randomIntNode;
@ -133,6 +136,7 @@ export const addDynamicPromptsToGraph = (
const rangeOfSizeNode: RangeOfSizeInvocation = { const rangeOfSizeNode: RangeOfSizeInvocation = {
id: RANGE_OF_SIZE, id: RANGE_OF_SIZE,
type: 'range_of_size', type: 'range_of_size',
is_intermediate: true,
size: iterations, size: iterations,
step: 1, step: 1,
}; };
@ -140,6 +144,7 @@ export const addDynamicPromptsToGraph = (
const iterateNode: IterateInvocation = { const iterateNode: IterateInvocation = {
id: ITERATE, id: ITERATE,
type: 'iterate', type: 'iterate',
is_intermediate: true,
}; };
graph.nodes[ITERATE] = iterateNode; graph.nodes[ITERATE] = iterateNode;
@ -186,6 +191,7 @@ export const addDynamicPromptsToGraph = (
const randomIntNode: RandomIntInvocation = { const randomIntNode: RandomIntInvocation = {
id: RANDOM_INT, id: RANDOM_INT,
type: 'rand_int', type: 'rand_int',
is_intermediate: true,
}; };
graph.nodes[RANDOM_INT] = randomIntNode; graph.nodes[RANDOM_INT] = randomIntNode;

View File

@ -60,6 +60,7 @@ export const addLoRAsToGraph = (
const loraLoaderNode: LoraLoaderInvocation = { const loraLoaderNode: LoraLoaderInvocation = {
type: 'lora_loader', type: 'lora_loader',
id: currentLoraNodeId, id: currentLoraNodeId,
is_intermediate: true,
lora: { model_name, base_model }, lora: { model_name, base_model },
weight, weight,
}; };

View File

@ -28,6 +28,7 @@ export const addVAEToGraph = (
graph.nodes[VAE_LOADER] = { graph.nodes[VAE_LOADER] = {
type: 'vae_loader', type: 'vae_loader',
id: VAE_LOADER, id: VAE_LOADER,
is_intermediate: true,
vae_model: vae, vae_model: vae,
}; };
} }

View File

@ -1,10 +1,9 @@
import { RootState } from 'app/store/store';
import { ImageDTO } from 'services/api/types';
import { log } from 'app/logging/useLogger'; import { log } from 'app/logging/useLogger';
import { forEach } from 'lodash-es'; import { RootState } from 'app/store/store';
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
import { NonNullableGraph } from 'features/nodes/types/types'; import { NonNullableGraph } from 'features/nodes/types/types';
import { ImageDTO } from 'services/api/types';
import { buildCanvasImageToImageGraph } from './buildCanvasImageToImageGraph'; import { buildCanvasImageToImageGraph } from './buildCanvasImageToImageGraph';
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
import { buildCanvasTextToImageGraph } from './buildCanvasTextToImageGraph'; import { buildCanvasTextToImageGraph } from './buildCanvasTextToImageGraph';
const moduleLog = log.child({ namespace: 'nodes' }); const moduleLog = log.child({ namespace: 'nodes' });
@ -31,9 +30,5 @@ export const buildCanvasGraph = (
graph = buildCanvasInpaintGraph(state, canvasInitImage, canvasMaskImage); graph = buildCanvasInpaintGraph(state, canvasInitImage, canvasMaskImage);
} }
forEach(graph.nodes, (node) => {
graph.nodes[node.id].is_intermediate = true;
});
return graph; return graph;
}; };

View File

@ -50,6 +50,8 @@ export const buildCanvasImageToImageGraph = (
// The bounding box determines width and height, not the width and height params // The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions; const { width, height } = state.canvas.boundingBoxDimensions;
const { shouldAutoSave } = state.canvas;
if (!model) { if (!model) {
moduleLog.error('No model found in state'); moduleLog.error('No model found in state');
throw new Error('No model found in state'); throw new Error('No model found in state');
@ -75,35 +77,42 @@ export const buildCanvasImageToImageGraph = (
[POSITIVE_CONDITIONING]: { [POSITIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: POSITIVE_CONDITIONING, id: POSITIVE_CONDITIONING,
is_intermediate: true,
prompt: positivePrompt, prompt: positivePrompt,
}, },
[NEGATIVE_CONDITIONING]: { [NEGATIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: NEGATIVE_CONDITIONING, id: NEGATIVE_CONDITIONING,
is_intermediate: true,
prompt: negativePrompt, prompt: negativePrompt,
}, },
[NOISE]: { [NOISE]: {
type: 'noise', type: 'noise',
id: NOISE, id: NOISE,
is_intermediate: true,
use_cpu, use_cpu,
}, },
[MAIN_MODEL_LOADER]: { [MAIN_MODEL_LOADER]: {
type: 'main_model_loader', type: 'main_model_loader',
id: MAIN_MODEL_LOADER, id: MAIN_MODEL_LOADER,
is_intermediate: true,
model, model,
}, },
[CLIP_SKIP]: { [CLIP_SKIP]: {
type: 'clip_skip', type: 'clip_skip',
id: CLIP_SKIP, id: CLIP_SKIP,
is_intermediate: true,
skipped_layers: clipSkip, skipped_layers: clipSkip,
}, },
[LATENTS_TO_IMAGE]: { [LATENTS_TO_IMAGE]: {
is_intermediate: !shouldAutoSave,
type: 'l2i', type: 'l2i',
id: LATENTS_TO_IMAGE, id: LATENTS_TO_IMAGE,
}, },
[LATENTS_TO_LATENTS]: { [LATENTS_TO_LATENTS]: {
type: 'l2l', type: 'l2l',
id: LATENTS_TO_LATENTS, id: LATENTS_TO_LATENTS,
is_intermediate: true,
cfg_scale, cfg_scale,
scheduler, scheduler,
steps, steps,
@ -112,6 +121,7 @@ export const buildCanvasImageToImageGraph = (
[IMAGE_TO_LATENTS]: { [IMAGE_TO_LATENTS]: {
type: 'i2l', type: 'i2l',
id: IMAGE_TO_LATENTS, id: IMAGE_TO_LATENTS,
is_intermediate: true,
// must be set manually later, bc `fit` parameter may require a resize node inserted // must be set manually later, bc `fit` parameter may require a resize node inserted
// image: { // image: {
// image_name: initialImage.image_name, // image_name: initialImage.image_name,

View File

@ -61,12 +61,17 @@ export const buildCanvasInpaintGraph = (
const { width, height } = state.canvas.boundingBoxDimensions; const { width, height } = state.canvas.boundingBoxDimensions;
// We may need to set the inpaint width and height to scale the image // We may need to set the inpaint width and height to scale the image
const { scaledBoundingBoxDimensions, boundingBoxScaleMethod } = state.canvas; const {
scaledBoundingBoxDimensions,
boundingBoxScaleMethod,
shouldAutoSave,
} = state.canvas;
const graph: NonNullableGraph = { const graph: NonNullableGraph = {
id: INPAINT_GRAPH, id: INPAINT_GRAPH,
nodes: { nodes: {
[INPAINT]: { [INPAINT]: {
is_intermediate: !shouldAutoSave,
type: 'inpaint', type: 'inpaint',
id: INPAINT, id: INPAINT,
steps, steps,
@ -100,26 +105,31 @@ export const buildCanvasInpaintGraph = (
[POSITIVE_CONDITIONING]: { [POSITIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: POSITIVE_CONDITIONING, id: POSITIVE_CONDITIONING,
is_intermediate: true,
prompt: positivePrompt, prompt: positivePrompt,
}, },
[NEGATIVE_CONDITIONING]: { [NEGATIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: NEGATIVE_CONDITIONING, id: NEGATIVE_CONDITIONING,
is_intermediate: true,
prompt: negativePrompt, prompt: negativePrompt,
}, },
[MAIN_MODEL_LOADER]: { [MAIN_MODEL_LOADER]: {
type: 'main_model_loader', type: 'main_model_loader',
id: MAIN_MODEL_LOADER, id: MAIN_MODEL_LOADER,
is_intermediate: true,
model, model,
}, },
[CLIP_SKIP]: { [CLIP_SKIP]: {
type: 'clip_skip', type: 'clip_skip',
id: CLIP_SKIP, id: CLIP_SKIP,
is_intermediate: true,
skipped_layers: clipSkip, skipped_layers: clipSkip,
}, },
[RANGE_OF_SIZE]: { [RANGE_OF_SIZE]: {
type: 'range_of_size', type: 'range_of_size',
id: RANGE_OF_SIZE, id: RANGE_OF_SIZE,
is_intermediate: true,
// seed - must be connected manually // seed - must be connected manually
// start: 0, // start: 0,
size: iterations, size: iterations,
@ -128,6 +138,7 @@ export const buildCanvasInpaintGraph = (
[ITERATE]: { [ITERATE]: {
type: 'iterate', type: 'iterate',
id: ITERATE, id: ITERATE,
is_intermediate: true,
}, },
}, },
edges: [ edges: [

View File

@ -41,6 +41,8 @@ export const buildCanvasTextToImageGraph = (
// The bounding box determines width and height, not the width and height params // The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions; const { width, height } = state.canvas.boundingBoxDimensions;
const { shouldAutoSave } = state.canvas;
if (!model) { if (!model) {
moduleLog.error('No model found in state'); moduleLog.error('No model found in state');
throw new Error('No model found in state'); throw new Error('No model found in state');
@ -66,16 +68,19 @@ export const buildCanvasTextToImageGraph = (
[POSITIVE_CONDITIONING]: { [POSITIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: POSITIVE_CONDITIONING, id: POSITIVE_CONDITIONING,
is_intermediate: true,
prompt: positivePrompt, prompt: positivePrompt,
}, },
[NEGATIVE_CONDITIONING]: { [NEGATIVE_CONDITIONING]: {
type: 'compel', type: 'compel',
id: NEGATIVE_CONDITIONING, id: NEGATIVE_CONDITIONING,
is_intermediate: true,
prompt: negativePrompt, prompt: negativePrompt,
}, },
[NOISE]: { [NOISE]: {
type: 'noise', type: 'noise',
id: NOISE, id: NOISE,
is_intermediate: true,
width, width,
height, height,
use_cpu, use_cpu,
@ -83,6 +88,7 @@ export const buildCanvasTextToImageGraph = (
[TEXT_TO_LATENTS]: { [TEXT_TO_LATENTS]: {
type: 't2l', type: 't2l',
id: TEXT_TO_LATENTS, id: TEXT_TO_LATENTS,
is_intermediate: true,
cfg_scale, cfg_scale,
scheduler, scheduler,
steps, steps,
@ -90,16 +96,19 @@ export const buildCanvasTextToImageGraph = (
[MAIN_MODEL_LOADER]: { [MAIN_MODEL_LOADER]: {
type: 'main_model_loader', type: 'main_model_loader',
id: MAIN_MODEL_LOADER, id: MAIN_MODEL_LOADER,
is_intermediate: true,
model, model,
}, },
[CLIP_SKIP]: { [CLIP_SKIP]: {
type: 'clip_skip', type: 'clip_skip',
id: CLIP_SKIP, id: CLIP_SKIP,
is_intermediate: true,
skipped_layers: clipSkip, skipped_layers: clipSkip,
}, },
[LATENTS_TO_IMAGE]: { [LATENTS_TO_IMAGE]: {
type: 'l2i', type: 'l2i',
id: LATENTS_TO_IMAGE, id: LATENTS_TO_IMAGE,
is_intermediate: !shouldAutoSave,
}, },
}, },
edges: [ edges: [

View File

@ -5,12 +5,11 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import useImageUploader from 'common/hooks/useImageUploader';
import { clearInitialImage } from 'features/parameters/store/generationSlice'; import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FaUndo, FaUpload } from 'react-icons/fa'; import { FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image';
import InitialImage from './InitialImage'; import InitialImage from './InitialImage';
import { PostUploadAction } from 'services/api/types';
const selector = createSelector( const selector = createSelector(
[stateSelector], [stateSelector],
@ -30,7 +29,6 @@ const postUploadAction: PostUploadAction = {
const InitialImageDisplay = () => { const InitialImageDisplay = () => {
const { isResetButtonDisabled } = useAppSelector(selector); const { isResetButtonDisabled } = useAppSelector(selector);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { openUploader } = useImageUploader();
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({ const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction, postUploadAction,
@ -40,10 +38,6 @@ const InitialImageDisplay = () => {
dispatch(clearInitialImage()); dispatch(clearInitialImage());
}, [dispatch]); }, [dispatch]);
const handleUpload = useCallback(() => {
openUploader();
}, [openUploader]);
return ( return (
<Flex <Flex
layerStyle={'first'} layerStyle={'first'}
@ -85,7 +79,6 @@ const InitialImageDisplay = () => {
tooltip={'Upload Initial Image'} tooltip={'Upload Initial Image'}
aria-label={'Upload Initial Image'} aria-label={'Upload Initial Image'}
icon={<FaUpload />} icon={<FaUpload />}
onClick={handleUpload}
{...getUploadButtonProps()} {...getUploadButtonProps()}
/> />
<IAIIconButton <IAIIconButton

View File

@ -244,22 +244,7 @@ export const useRecallParameters = () => {
[dispatch, parameterSetToast, parameterNotSetToast] [dispatch, parameterSetToast, parameterNotSetToast]
); );
/** /*
* Sets initial image with toast
*/
const recallInitialImage = useCallback(
async (image: unknown) => {
if (!isImageField(image)) {
parameterNotSetToast();
return;
}
dispatch(initialImageSelected(image.image_name));
parameterSetToast();
},
[dispatch, parameterSetToast, parameterNotSetToast]
);
/**
* Sets image as initial image with toast * Sets image as initial image with toast
*/ */
const sendToImageToImage = useCallback( const sendToImageToImage = useCallback(
@ -330,7 +315,6 @@ export const useRecallParameters = () => {
recallPositivePrompt, recallPositivePrompt,
recallNegativePrompt, recallNegativePrompt,
recallSeed, recallSeed,
recallInitialImage,
recallCfgScale, recallCfgScale,
recallModel, recallModel,
recallScheduler, recallScheduler,

View File

@ -1,7 +1,7 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import { ImageDTO, MainModelField } from 'services/api/types'; import { ImageDTO, MainModelField } from 'services/api/types';
export const initialImageSelected = createAction<ImageDTO | string | undefined>( export const initialImageSelected = createAction<ImageDTO | undefined>(
'generation/initialImageSelected' 'generation/initialImageSelected'
); );

View File

@ -6,7 +6,6 @@ import { userInvoked } from 'app/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice'; import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice';
import { t } from 'i18next'; import { t } from 'i18next';
import { LogLevelName } from 'roarr'; import { LogLevelName } from 'roarr';
import { imageUploaded } from 'services/api/thunks/image';
import { import {
isAnySessionRejected, isAnySessionRejected,
sessionCanceled, sessionCanceled,
@ -360,27 +359,6 @@ export const systemSlice = createSlice({
state.wasSchemaParsed = true; state.wasSchemaParsed = true;
}); });
/**
* Image Uploading Started
*/
builder.addCase(imageUploaded.pending, (state) => {
state.isUploading = true;
});
/**
* Image Uploading Complete
*/
builder.addCase(imageUploaded.rejected, (state) => {
state.isUploading = false;
});
/**
* Image Uploading Complete
*/
builder.addCase(imageUploaded.fulfilled, (state) => {
state.isUploading = false;
});
// *** Matchers - must be after all cases *** // *** Matchers - must be after all cases ***
/** /**

View File

@ -17,14 +17,14 @@ type ModelListProps = {
setSelectedModelId: (name: string | undefined) => void; setSelectedModelId: (name: string | undefined) => void;
}; };
type ModelFormat = 'all' | 'checkpoint' | 'diffusers'; type ModelFormat = 'images' | 'checkpoint' | 'diffusers';
const ModelList = (props: ModelListProps) => { const ModelList = (props: ModelListProps) => {
const { selectedModelId, setSelectedModelId } = props; const { selectedModelId, setSelectedModelId } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [nameFilter, setNameFilter] = useState<string>(''); const [nameFilter, setNameFilter] = useState<string>('');
const [modelFormatFilter, setModelFormatFilter] = const [modelFormatFilter, setModelFormatFilter] =
useState<ModelFormat>('all'); useState<ModelFormat>('images');
const { filteredDiffusersModels } = useGetMainModelsQuery(undefined, { const { filteredDiffusersModels } = useGetMainModelsQuery(undefined, {
selectFromResult: ({ data }) => ({ selectFromResult: ({ data }) => ({
@ -47,8 +47,8 @@ const ModelList = (props: ModelListProps) => {
<Flex flexDirection="column" gap={4} paddingInlineEnd={4}> <Flex flexDirection="column" gap={4} paddingInlineEnd={4}>
<ButtonGroup isAttached> <ButtonGroup isAttached>
<IAIButton <IAIButton
onClick={() => setModelFormatFilter('all')} onClick={() => setModelFormatFilter('images')}
isChecked={modelFormatFilter === 'all'} isChecked={modelFormatFilter === 'images'}
size="sm" size="sm"
> >
{t('modelManager.allModels')} {t('modelManager.allModels')}
@ -75,7 +75,7 @@ const ModelList = (props: ModelListProps) => {
labelPos="side" labelPos="side"
/> />
{['all', 'diffusers'].includes(modelFormatFilter) && {['images', 'diffusers'].includes(modelFormatFilter) &&
filteredDiffusersModels.length > 0 && ( filteredDiffusersModels.length > 0 && (
<StyledModelContainer> <StyledModelContainer>
<Flex sx={{ gap: 2, flexDir: 'column' }}> <Flex sx={{ gap: 2, flexDir: 'column' }}>
@ -93,7 +93,7 @@ const ModelList = (props: ModelListProps) => {
</Flex> </Flex>
</StyledModelContainer> </StyledModelContainer>
)} )}
{['all', 'checkpoint'].includes(modelFormatFilter) && {['images', 'checkpoint'].includes(modelFormatFilter) &&
filteredCheckpointModels.length > 0 && ( filteredCheckpointModels.length > 0 && (
<StyledModelContainer> <StyledModelContainer>
<Flex sx={{ gap: 2, flexDir: 'column' }}> <Flex sx={{ gap: 2, flexDir: 'column' }}>

View File

@ -1,22 +1,28 @@
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import useImageUploader from 'common/hooks/useImageUploader'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors'; import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa'; import { FaUpload } from 'react-icons/fa';
export default function UnifiedCanvasFileUploader() { export default function UnifiedCanvasFileUploader() {
const isStaging = useAppSelector(isStagingSelector); const isStaging = useAppSelector(isStagingSelector);
const { openUploader } = useImageUploader();
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction: { type: 'SET_CANVAS_INITIAL_IMAGE' },
});
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<>
<IAIIconButton <IAIIconButton
aria-label={t('common.upload')} aria-label={t('common.upload')}
tooltip={t('common.upload')} tooltip={t('common.upload')}
icon={<FaUpload />} icon={<FaUpload />}
onClick={openUploader}
isDisabled={isStaging} isDisabled={isStaging}
{...getUploadButtonProps()}
/> />
<input {...getUploadInputProps()} />
</>
); );
} }

View File

@ -1,6 +1,7 @@
import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types'; import { ImageDTO, OffsetPaginatedResults_ImageDTO_ } from 'services/api/types';
import { ApiFullTagDescription, LIST_TAG, api } from '..'; import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema'; import { paths } from '../schema';
import { BoardId } from 'features/gallery/store/gallerySlice';
type ListBoardImagesArg = type ListBoardImagesArg =
paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] & paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] &
@ -45,39 +46,7 @@ export const boardImagesApi = api.injectEndpoints({
return tags; return tags;
}, },
}), }),
/**
* Board Images Mutations
*/
addImageToBoard: build.mutation<void, AddImageToBoardArg>({
query: ({ board_id, image_name }) => ({
url: `board_images/`,
method: 'POST',
body: { board_id, image_name },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id },
],
}),
removeImageFromBoard: build.mutation<void, RemoveImageFromBoardArg>({
query: ({ board_id, image_name }) => ({
url: `board_images/`,
method: 'DELETE',
body: { board_id, image_name },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id },
],
}),
}), }),
}); });
export const { export const { useListBoardImagesQuery } = boardImagesApi;
useAddImageToBoardMutation,
useRemoveImageFromBoardMutation,
useListBoardImagesQuery,
} = boardImagesApi;

View File

@ -1,6 +1,17 @@
import { BoardDTO, OffsetPaginatedResults_BoardDTO_ } from 'services/api/types'; import { Update } from '@reduxjs/toolkit';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import {
BoardDTO,
ImageDTO,
OffsetPaginatedResults_BoardDTO_,
} from 'services/api/types';
import { ApiFullTagDescription, LIST_TAG, api } from '..'; import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema'; import { paths } from '../schema';
import { getListImagesUrl, imagesAdapter, imagesApi } from './images';
type ListBoardsArg = NonNullable< type ListBoardsArg = NonNullable<
paths['/api/v1/boards/']['get']['parameters']['query'] paths['/api/v1/boards/']['get']['parameters']['query']
@ -11,6 +22,9 @@ type UpdateBoardArg =
changes: paths['/api/v1/boards/{board_id}']['patch']['requestBody']['content']['application/json']; changes: paths['/api/v1/boards/{board_id}']['patch']['requestBody']['content']['application/json'];
}; };
type DeleteBoardResult =
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'];
export const boardsApi = api.injectEndpoints({ export const boardsApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
/** /**
@ -59,6 +73,16 @@ export const boardsApi = api.injectEndpoints({
}, },
}), }),
listAllImageNamesForBoard: build.query<Array<string>, string>({
query: (board_id) => ({
url: `boards/${board_id}/image_names`,
}),
providesTags: (result, error, arg) => [
{ type: 'ImageNameList', id: arg },
],
keepUnusedDataFor: 0,
}),
/** /**
* Boards Mutations * Boards Mutations
*/ */
@ -82,11 +106,92 @@ export const boardsApi = api.injectEndpoints({
{ type: 'Board', id: arg.board_id }, { type: 'Board', id: arg.board_id },
], ],
}), }),
deleteBoard: build.mutation<void, string>({
deleteBoard: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }),
invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }], invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg },
// invalidate the 'No Board' cache
{ type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) },
],
async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) {
/**
* Cache changes for deleteBoard:
* - Update every image in the 'getImageDTO' cache that has the board_id
* - Update every image in the 'All Images' cache that has the board_id
* - Update every image in the 'All Assets' cache that has the board_id
* - Invalidate the 'No Board' cache:
* Ideally we'd be able to insert all deleted images into the cache, but we don't
* have access to the deleted images DTOs - only the names, and a network request
* for all of a board's DTOs could be very large. Instead, we invalidate the 'No Board'
* cache.
*/
try {
const { data } = await queryFulfilled;
const { deleted_board_images } = data;
// update getImageDTO caches
deleted_board_images.forEach((image_id) => {
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_id,
(draft) => {
draft.board_id = undefined;
}
)
);
});
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
const updates: Update<ImageDTO>[] = deleted_board_images.map(
(image_name) => ({
id: image_name,
changes: { board_id: undefined },
})
);
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
const oldCount = imagesAdapter
.getSelectors()
.selectTotal(draft);
const newState = imagesAdapter.updateMany(draft, updates);
const newCount = imagesAdapter
.getSelectors()
.selectTotal(newState);
draft.total = Math.max(
draft.total - (oldCount - newCount),
0
);
}
)
);
});
// after deleting a board, select the 'All Images' board
dispatch(boardIdSelected('images'));
} catch {
//no-op
}
},
}), }),
deleteBoardAndImages: build.mutation<void, string>({
deleteBoardAndImages: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ query: (board_id) => ({
url: `boards/${board_id}`, url: `boards/${board_id}`,
method: 'DELETE', method: 'DELETE',
@ -94,8 +199,63 @@ export const boardsApi = api.injectEndpoints({
}), }),
invalidatesTags: (result, error, arg) => [ invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg }, { type: 'Board', id: arg },
{ type: 'Image', id: LIST_TAG }, { type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) },
], ],
async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) {
/**
* Cache changes for deleteBoardAndImages:
* - ~~Remove every image in the 'getImageDTO' cache that has the board_id~~
* This isn't actually possible, you cannot remove cache entries with RTK Query.
* Instead, we rely on the UI to remove all components that use the deleted images.
* - Remove every image in the 'All Images' cache that has the board_id
* - Remove every image in the 'All Assets' cache that has the board_id
*/
try {
const { data } = await queryFulfilled;
const { deleted_images } = data;
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
const oldCount = imagesAdapter
.getSelectors()
.selectTotal(draft);
const newState = imagesAdapter.removeMany(
draft,
deleted_images
);
const newCount = imagesAdapter
.getSelectors()
.selectTotal(newState);
draft.total = Math.max(
draft.total - (oldCount - newCount),
0
);
}
)
);
});
// after deleting a board, select the 'All Images' board
dispatch(boardIdSelected('images'));
} catch {
//no-op
}
},
}), }),
}), }),
}); });
@ -107,4 +267,5 @@ export const {
useUpdateBoardMutation, useUpdateBoardMutation,
useDeleteBoardMutation, useDeleteBoardMutation,
useDeleteBoardAndImagesMutation, useDeleteBoardAndImagesMutation,
useListAllImageNamesForBoardQuery,
} = boardsApi; } = boardsApi;

View File

@ -1,7 +1,27 @@
import { EntityState, createEntityAdapter } from '@reduxjs/toolkit';
import { PatchCollection } from '@reduxjs/toolkit/dist/query/core/buildThunks';
import { dateComparator } from 'common/util/dateComparator';
import {
ASSETS_CATEGORIES,
BoardId,
IMAGE_CATEGORIES,
} from 'features/gallery/store/gallerySlice';
import { omit } from 'lodash-es';
import queryString from 'query-string';
import { ApiFullTagDescription, api } from '..'; import { ApiFullTagDescription, api } from '..';
import { components } from '../schema'; import { components, paths } from '../schema';
import { ImageDTO } from '../types'; import {
ImageCategory,
ImageChanges,
ImageDTO,
OffsetPaginatedResults_ImageDTO_,
PostUploadAction,
} from '../types';
import { getCacheAction } from './util';
export type ListImagesArgs = NonNullable<
paths['/api/v1/images/']['get']['parameters']['query']
>;
/** /**
* This is an unsafe type; the object inside is not guaranteed to be valid. * This is an unsafe type; the object inside is not guaranteed to be valid.
@ -11,11 +31,102 @@ export type UnsafeImageMetadata = {
graph: NonNullable<components['schemas']['Graph']>; graph: NonNullable<components['schemas']['Graph']>;
}; };
export type ImageCache = EntityState<ImageDTO> & { total: number };
// The adapter is not actually the data store - it just provides helper functions to interact
// with some other store of data. We will use the RTK Query cache as that store.
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
// We want to also store the images total in the cache. When we initialize the cache state,
// we will provide this type arg so the adapter knows we want the total.
export type AdditionalImagesAdapterState = { total: number };
// Create selectors for the adapter.
export const imagesSelectors = imagesAdapter.getSelectors();
// Helper to create the url for the listImages endpoint. Also we use it to create the cache key.
export const getListImagesUrl = (queryArgs: ListImagesArgs) =>
`images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`;
export const SYSTEM_BOARDS = ['images', 'assets', 'no_board', 'batch'];
export const imagesApi = api.injectEndpoints({ export const imagesApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
/** /**
* Image Queries * Image Queries
*/ */
listImages: build.query<
EntityState<ImageDTO> & { total: number },
ListImagesArgs
>({
query: (queryArgs) => ({
// Use the helper to create the URL.
url: getListImagesUrl(queryArgs),
method: 'GET',
}),
providesTags: (result, error, { board_id, categories }) => [
// Make the tags the same as the cache key
{ type: 'ImageList', id: getListImagesUrl({ board_id, categories }) },
],
serializeQueryArgs: ({ queryArgs }) => {
// Create cache & key based on board_id and categories - skip the other args.
// Offset is the size of the cache, and limit is always the same. Both are provided by
// the consumer of the query.
const { board_id, categories } = queryArgs;
// Just use the same fn used to create the url; it makes an understandable cache key.
// This cache key is the same for any combo of board_id and categories, doesn't change
// when offset & limit change.
const cacheKey = getListImagesUrl({ board_id, categories });
return cacheKey;
},
transformResponse(response: OffsetPaginatedResults_ImageDTO_) {
const { total, items: images } = response;
// Use the adapter to convert the response to the right shape, and adding the new total.
// The trick is to just provide an empty state and add the images array to it. This returns
// a properly shaped EntityState.
return imagesAdapter.addMany(
imagesAdapter.getInitialState<AdditionalImagesAdapterState>({
total,
}),
images
);
},
merge: (cache, response) => {
// Here we actually update the cache. `response` here is the output of `transformResponse`
// above. In a similar vein to `transformResponse`, we can use the imagesAdapter to get
// things in the right shape. Also update the total image count.
imagesAdapter.addMany(cache, imagesSelectors.selectAll(response));
cache.total = response.total;
},
forceRefetch({ currentArg, previousArg }) {
// Refetch when the offset changes (which means we are on a new page).
return currentArg?.offset !== previousArg?.offset;
},
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
// update the `getImageDTO` cache for each image
imagesSelectors.selectAll(data).forEach((imageDTO) => {
dispatch(
imagesApi.util.upsertQueryData(
'getImageDTO',
imageDTO.image_name,
imageDTO
)
);
});
} catch {
// no-op
}
},
// 24 hours - reducing this to a few minutes would reduce memory usage.
keepUnusedDataFor: 86400,
}),
getImageDTO: build.query<ImageDTO, string>({ getImageDTO: build.query<ImageDTO, string>({
query: (image_name) => ({ url: `images/${image_name}` }), query: (image_name) => ({ url: `images/${image_name}` }),
providesTags: (result, error, arg) => { providesTags: (result, error, arg) => {
@ -40,7 +151,480 @@ export const imagesApi = api.injectEndpoints({
clearIntermediates: build.mutation({ clearIntermediates: build.mutation({
query: () => ({ url: `images/clear-intermediates`, method: 'POST' }), query: () => ({ url: `images/clear-intermediates`, method: 'POST' }),
}), }),
deleteImage: build.mutation<void, ImageDTO>({
query: ({ image_name }) => ({
url: `images/${image_name}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Image', id: arg.image_name },
],
async onQueryStarted(imageDTO, { dispatch, queryFulfilled }) {
/**
* Cache changes for deleteImage:
* - Remove from "All Images"
* - Remove from image's `board_id` if it has one, or "No Board" if not
* - Remove from "Batch"
*/
const { image_name, board_id, image_category } = imageDTO;
// Figure out the `listImages` caches that we need to update
// That means constructing the possible query args that are serialized into the cache key...
const removeFromCacheKeys: ListImagesArgs[] = [];
const categories = IMAGE_CATEGORIES.includes(image_category)
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// All Images board (e.g. no board)
removeFromCacheKeys.push({ categories });
// Board specific
if (board_id) {
removeFromCacheKeys.push({ board_id });
} else {
// TODO: No Board
}
// TODO: Batch
const patches: PatchCollection[] = [];
removeFromCacheKeys.forEach((cacheKey) => {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
cacheKey,
(draft) => {
imagesAdapter.removeOne(draft, image_name);
draft.total = Math.max(draft.total - 1, 0);
}
)
)
);
});
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
updateImage: build.mutation<
ImageDTO,
{
imageDTO: ImageDTO;
// For now, we will not allow image categories to change
changes: Omit<ImageChanges, 'image_category'>;
}
>({
query: ({ imageDTO, changes }) => ({
url: `images/${imageDTO.image_name}`,
method: 'PATCH',
body: changes,
}),
invalidatesTags: (result, error, { imageDTO }) => [
{ type: 'Image', id: imageDTO.image_name },
],
async onQueryStarted(
{ imageDTO: oldImageDTO, changes: _changes },
{ dispatch, queryFulfilled, getState }
) {
// TODO: Should we handle changes to boards via this mutation? Seems reasonable...
// let's be extra-sure we do not accidentally change categories
const changes = omit(_changes, 'image_category');
/**
* Cache changes for `updateImage`:
* - Update the ImageDTO
* - Update the image in "All Images" board:
* - IF it is in the date range represented by the cache:
* - add the image IF it is not already in the cache & update the total
* - ELSE update the image IF it is already in the cache
* - IF the image has a board:
* - Update the image in it's own board
* - ELSE Update the image in the "No Board" board (TODO)
*/
const patches: PatchCollection[] = [];
const { image_name, board_id, image_category } = oldImageDTO;
const categories = IMAGE_CATEGORIES.includes(image_category)
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// TODO: No Board
// Update `getImageDTO` cache
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
Object.assign(draft, changes);
}
)
)
);
// Update the "All Image" or "All Assets" board
const queryArgsToUpdate: ListImagesArgs[] = [{ categories }];
if (board_id) {
// We also need to update the user board
queryArgsToUpdate.push({ board_id });
}
queryArgsToUpdate.forEach((queryArg) => {
const { data } = imagesApi.endpoints.listImages.select(queryArg)(
getState()
);
const cacheAction = getCacheAction(data, oldImageDTO);
if (['update', 'add'].includes(cacheAction)) {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArg,
(draft) => {
// One of the common changes is to make a canvas intermediate a non-intermediate,
// i.e. save a canvas image to the gallery.
// If that was the change, need to add the image to the cache instead of updating
// the existing cache entry.
if (
changes.is_intermediate === false ||
cacheAction === 'add'
) {
// add it to the cache
imagesAdapter.addOne(draft, {
...oldImageDTO,
...changes,
});
draft.total += 1;
} else if (cacheAction === 'update') {
// just update it
imagesAdapter.updateOne(draft, {
id: image_name,
changes,
});
}
}
)
)
);
}
});
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
uploadImage: build.mutation<
ImageDTO,
{
file: File;
image_category: ImageCategory;
is_intermediate: boolean;
postUploadAction?: PostUploadAction;
session_id?: string;
}
>({
query: ({ file, image_category, is_intermediate, session_id }) => {
const formData = new FormData();
formData.append('file', file);
return {
url: `images/`,
method: 'POST',
body: formData,
params: {
image_category,
is_intermediate,
session_id,
},
};
},
async onQueryStarted(
{ file, image_category, is_intermediate, postUploadAction },
{ dispatch, queryFulfilled }
) {
try {
const { data: imageDTO } = await queryFulfilled;
if (imageDTO.is_intermediate) {
// Don't add it to anything
return;
}
// Add the image to the "All Images" / "All Assets" board
const queryArg = {
categories: IMAGE_CATEGORIES.includes(image_category)
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES,
};
dispatch(
imagesApi.util.updateQueryData('listImages', queryArg, (draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
})
);
} catch {
// no-op
}
},
}),
addImageToBoard: build.mutation<
void,
{ board_id: BoardId; imageDTO: ImageDTO }
>({
query: ({ board_id, imageDTO }) => {
const { image_name } = imageDTO;
return {
url: `board_images/`,
method: 'POST',
body: { board_id, image_name },
};
},
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id },
],
async onQueryStarted(
{ board_id, imageDTO: oldImageDTO },
{ dispatch, queryFulfilled, getState }
) {
/**
* Cache changes for addImageToBoard:
* - Remove from "No Board"
* - Remove from `old_board_id` if it has one
* - Add to new `board_id`
* - IF the image's `created_at` is within the range of the board's cached images
* - OR the board cache has length of 0 or 1
* - Update the `total` for each board whose cache is updated
* - Update the ImageDTO
*
* TODO: maybe total should just be updated in the boards endpoints?
*/
const { image_name, board_id: old_board_id } = oldImageDTO;
// Figure out the `listImages` caches that we need to update
const removeFromQueryArgs: ListImagesArgs[] = [];
// TODO: No Board
// TODO: Batch
// Remove from No Board
removeFromQueryArgs.push({ board_id: 'none' });
// Remove from old board
if (old_board_id) {
removeFromQueryArgs.push({ board_id: old_board_id });
}
// Store all patch results in case we need to roll back
const patches: PatchCollection[] = [];
// Updated imageDTO with new board_id
const newImageDTO = { ...oldImageDTO, board_id };
// Update getImageDTO cache
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
Object.assign(draft, newImageDTO);
}
)
)
);
// Do the "Remove from" cache updates
removeFromQueryArgs.forEach((queryArgs) => {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
// sanity check
if (draft.ids.includes(image_name)) {
imagesAdapter.removeOne(draft, image_name);
draft.total = Math.max(draft.total - 1, 0);
}
}
)
)
);
});
// We only need to add to the cache if the board is not a system board
if (!SYSTEM_BOARDS.includes(board_id)) {
const queryArgs = { board_id };
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(
getState()
);
const cacheAction = getCacheAction(data, oldImageDTO);
if (['add', 'update'].includes(cacheAction)) {
// Do the "Add to" cache updates
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
if (cacheAction === 'add') {
imagesAdapter.addOne(draft, newImageDTO);
draft.total += 1;
} else {
imagesAdapter.updateOne(draft, {
id: image_name,
changes: { board_id },
});
}
}
)
)
);
}
}
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
removeImageFromBoard: build.mutation<void, { imageDTO: ImageDTO }>({
query: ({ imageDTO }) => {
const { board_id, image_name } = imageDTO;
return {
url: `board_images/`,
method: 'DELETE',
body: { board_id, image_name },
};
},
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.imageDTO.board_id },
],
async onQueryStarted(
{ imageDTO },
{ dispatch, queryFulfilled, getState }
) {
/**
* Cache changes for removeImageFromBoard:
* - Add to "No Board"
* - IF the image's `created_at` is within the range of the board's cached images
* - Remove from `old_board_id`
* - Update the ImageDTO
*/
const { image_name, board_id: old_board_id } = imageDTO;
// TODO: Batch
const patches: PatchCollection[] = [];
// Updated imageDTO with new board_id
const newImageDTO = { ...imageDTO, board_id: undefined };
// Update getImageDTO cache
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
Object.assign(draft, newImageDTO);
}
)
)
);
// Remove from old board
if (old_board_id) {
const oldBoardQueryArgs = { board_id: old_board_id };
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
oldBoardQueryArgs,
(draft) => {
// sanity check
if (draft.ids.includes(image_name)) {
imagesAdapter.removeOne(draft, image_name);
draft.total = Math.max(draft.total - 1, 0);
}
}
)
)
);
}
// Add to "No Board"
const noBoardQueryArgs = { board_id: 'none' };
const { data } = imagesApi.endpoints.listImages.select(
noBoardQueryArgs
)(getState());
// Check if we need to make any cache changes
const cacheAction = getCacheAction(data, imageDTO);
if (['add', 'update'].includes(cacheAction)) {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
noBoardQueryArgs,
(draft) => {
if (cacheAction === 'add') {
imagesAdapter.addOne(draft, imageDTO);
draft.total += 1;
} else {
imagesAdapter.updateOne(draft, {
id: image_name,
changes: { board_id: undefined },
});
}
}
)
)
);
}
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
}), }),
}); });
export const { useGetImageDTOQuery, useGetImageMetadataQuery, useClearIntermediatesMutation } = imagesApi; export const {
useListImagesQuery,
useLazyListImagesQuery,
useGetImageDTOQuery,
useGetImageMetadataQuery,
useDeleteImageMutation,
useUpdateImageMutation,
useUploadImageMutation,
useAddImageToBoardMutation,
useRemoveImageFromBoardMutation,
useClearIntermediatesMutation,
} = imagesApi;

View File

@ -0,0 +1,51 @@
import { ImageDTO } from '../types';
import { ImageCache, imagesSelectors } from './images';
export const getIsImageInDateRange = (
data: ImageCache | undefined,
imageDTO: ImageDTO
) => {
if (!data) {
return false;
}
const cacheImageDTOS = imagesSelectors.selectAll(data);
if (cacheImageDTOS.length > 1) {
// Images are sorted by `created_at` DESC
// check if the image is newer than the oldest image in the cache
const createdDate = new Date(imageDTO.created_at);
const oldestDate = new Date(
cacheImageDTOS[cacheImageDTOS.length - 1].created_at
);
return createdDate >= oldestDate;
} else if ([0, 1].includes(cacheImageDTOS.length)) {
// if there are only 1 or 0 images in the cache, we consider the image to be in the date range
return true;
}
return false;
};
/**
* Determines the action we should take when an image may need to be added or updated in a cache.
*/
export const getCacheAction = (
data: ImageCache | undefined,
imageDTO: ImageDTO
): 'add' | 'update' | 'none' => {
const isInDateRange = getIsImageInDateRange(data, imageDTO);
const isCacheFullyPopulated = data && data.total === data.ids.length;
const shouldUpdateCache =
Boolean(isInDateRange) || Boolean(isCacheFullyPopulated);
const isImageInCache = data && data.ids.includes(imageDTO.image_name);
if (shouldUpdateCache && isImageInCache) {
return 'update';
}
if (shouldUpdateCache && !isImageInCache) {
return 'add';
}
return 'none';
};

View File

@ -8,7 +8,14 @@ import {
} from '@reduxjs/toolkit/query/react'; } from '@reduxjs/toolkit/query/react';
import { $authToken, $baseUrl } from 'services/api/client'; import { $authToken, $baseUrl } from 'services/api/client';
export const tagTypes = ['Board', 'Image', 'ImageMetadata', 'Model']; export const tagTypes = [
'Board',
'Image',
'ImageNameList',
'ImageList',
'ImageMetadata',
'Model',
];
export type ApiFullTagDescription = FullTagDescription< export type ApiFullTagDescription = FullTagDescription<
(typeof tagTypes)[number] (typeof tagTypes)[number]
>; >;

View File

@ -228,6 +228,13 @@ export type paths = {
*/ */
patch: operations["update_board"]; patch: operations["update_board"];
}; };
"/api/v1/boards/{board_id}/image_names": {
/**
* List All Board Image Names
* @description Gets a list of images for a board
*/
get: operations["list_all_board_image_names"];
};
"/api/v1/board_images/": { "/api/v1/board_images/": {
/** /**
* Create Board Image * Create Board Image
@ -240,13 +247,6 @@ export type paths = {
*/ */
delete: operations["remove_board_image"]; delete: operations["remove_board_image"];
}; };
"/api/v1/board_images/{board_id}": {
/**
* List Board Images
* @description Gets a list of images for a board
*/
get: operations["list_board_images"];
};
"/api/v1/app/version": { "/api/v1/app/version": {
/** Get Version */ /** Get Version */
get: operations["app_version"]; get: operations["app_version"];
@ -1037,6 +1037,24 @@ export type components = {
*/ */
mask?: components["schemas"]["ImageField"]; mask?: components["schemas"]["ImageField"];
}; };
/** DeleteBoardResult */
DeleteBoardResult: {
/**
* Board Id
* @description The id of the board that was deleted.
*/
board_id: string;
/**
* Deleted Board Images
* @description The image names of the board-images relationships that were deleted.
*/
deleted_board_images: (string)[];
/**
* Deleted Images
* @description The names of the images that were deleted.
*/
deleted_images: (string)[];
};
/** /**
* DivideInvocation * DivideInvocation
* @description Divides two numbers * @description Divides two numbers
@ -1261,7 +1279,7 @@ export type components = {
* @description The nodes in this graph * @description The nodes in this graph
*/ */
nodes?: { nodes?: {
[key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRawPromptInvocation"] | components["schemas"]["SDXLRefinerRawPromptInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["ParamStringInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SDXLTextToLatentsInvocation"] | components["schemas"]["SDXLLatentsToLatentsInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined; [key: string]: (components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRawPromptInvocation"] | components["schemas"]["SDXLRefinerRawPromptInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SDXLTextToLatentsInvocation"] | components["schemas"]["SDXLLatentsToLatentsInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["ParamStringInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]) | undefined;
}; };
/** /**
* Edges * Edges
@ -1304,7 +1322,7 @@ export type components = {
* @description The results of node executions * @description The results of node executions
*/ */
results: { results: {
[key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["VaeLoaderOutput"] | components["schemas"]["MetadataAccumulatorOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined; [key: string]: (components["schemas"]["ImageOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["LoraLoaderOutput"] | components["schemas"]["VaeLoaderOutput"] | components["schemas"]["MetadataAccumulatorOutput"] | components["schemas"]["CompelOutput"] | components["schemas"]["ClipSkipInvocationOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["PromptOutput"] | components["schemas"]["PromptCollectionOutput"] | components["schemas"]["IntOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["IntCollectionOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["GraphInvocationOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["CollectInvocationOutput"]) | undefined;
}; };
/** /**
* Errors * Errors
@ -5306,11 +5324,11 @@ export type components = {
image?: components["schemas"]["ImageField"]; image?: components["schemas"]["ImageField"];
}; };
/** /**
* StableDiffusionXLModelFormat * StableDiffusion1ModelFormat
* @description An enumeration. * @description An enumeration.
* @enum {string} * @enum {string}
*/ */
StableDiffusionXLModelFormat: "checkpoint" | "diffusers"; StableDiffusion1ModelFormat: "checkpoint" | "diffusers";
/** /**
* StableDiffusion2ModelFormat * StableDiffusion2ModelFormat
* @description An enumeration. * @description An enumeration.
@ -5318,11 +5336,11 @@ export type components = {
*/ */
StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; StableDiffusion2ModelFormat: "checkpoint" | "diffusers";
/** /**
* StableDiffusion1ModelFormat * StableDiffusionXLModelFormat
* @description An enumeration. * @description An enumeration.
* @enum {string} * @enum {string}
*/ */
StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; StableDiffusionXLModelFormat: "checkpoint" | "diffusers";
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@ -5433,7 +5451,7 @@ export type operations = {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRawPromptInvocation"] | components["schemas"]["SDXLRefinerRawPromptInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["ParamStringInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SDXLTextToLatentsInvocation"] | components["schemas"]["SDXLLatentsToLatentsInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRawPromptInvocation"] | components["schemas"]["SDXLRefinerRawPromptInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SDXLTextToLatentsInvocation"] | components["schemas"]["SDXLLatentsToLatentsInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["ParamStringInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
}; };
}; };
responses: { responses: {
@ -5470,7 +5488,7 @@ export type operations = {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRawPromptInvocation"] | components["schemas"]["SDXLRefinerRawPromptInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["ParamStringInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SDXLTextToLatentsInvocation"] | components["schemas"]["SDXLLatentsToLatentsInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"]; "application/json": components["schemas"]["LoadImageInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["ImageProcessorInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["LoraLoaderInvocation"] | components["schemas"]["VaeLoaderInvocation"] | components["schemas"]["MetadataAccumulatorInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRawPromptInvocation"] | components["schemas"]["SDXLRefinerRawPromptInvocation"] | components["schemas"]["ClipSkipInvocation"] | components["schemas"]["TextToLatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SDXLTextToLatentsInvocation"] | components["schemas"]["SDXLLatentsToLatentsInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["AddInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["ParamIntInvocation"] | components["schemas"]["ParamFloatInvocation"] | components["schemas"]["ParamStringInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["StepParamEasingInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["InpaintInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["GraphInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CannyImageProcessorInvocation"] | components["schemas"]["HedImageProcessorInvocation"] | components["schemas"]["LineartImageProcessorInvocation"] | components["schemas"]["LineartAnimeImageProcessorInvocation"] | components["schemas"]["OpenposeImageProcessorInvocation"] | components["schemas"]["MidasDepthImageProcessorInvocation"] | components["schemas"]["NormalbaeImageProcessorInvocation"] | components["schemas"]["MlsdImageProcessorInvocation"] | components["schemas"]["PidiImageProcessorInvocation"] | components["schemas"]["ContentShuffleImageProcessorInvocation"] | components["schemas"]["ZoeDepthImageProcessorInvocation"] | components["schemas"]["MediapipeFaceProcessorInvocation"] | components["schemas"]["LeresImageProcessorInvocation"] | components["schemas"]["TileResamplerProcessorInvocation"] | components["schemas"]["SegmentAnythingProcessorInvocation"] | components["schemas"]["LatentsToLatentsInvocation"];
}; };
}; };
responses: { responses: {
@ -5956,13 +5974,13 @@ export type operations = {
list_image_dtos: { list_image_dtos: {
parameters: { parameters: {
query?: { query?: {
/** @description The origin of images to list */ /** @description The origin of images to list. */
image_origin?: components["schemas"]["ResourceOrigin"]; image_origin?: components["schemas"]["ResourceOrigin"];
/** @description The categories of image to include */ /** @description The categories of image to include. */
categories?: (components["schemas"]["ImageCategory"])[]; categories?: (components["schemas"]["ImageCategory"])[];
/** @description Whether to list intermediate images */ /** @description Whether to list intermediate images. */
is_intermediate?: boolean; is_intermediate?: boolean;
/** @description The board id to filter by */ /** @description The board id to filter by. Use 'none' to find images without a board. */
board_id?: string; board_id?: string;
/** @description The page offset */ /** @description The page offset */
offset?: number; offset?: number;
@ -6328,7 +6346,7 @@ export type operations = {
/** @description Successful Response */ /** @description Successful Response */
200: { 200: {
content: { content: {
"application/json": unknown; "application/json": components["schemas"]["DeleteBoardResult"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@ -6370,6 +6388,32 @@ export type operations = {
}; };
}; };
}; };
/**
* List All Board Image Names
* @description Gets a list of images for a board
*/
list_all_board_image_names: {
parameters: {
path: {
/** @description The id of the board */
board_id: string;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": (string)[];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** /**
* Create Board Image * Create Board Image
* @description Creates a board_image * @description Creates a board_image
@ -6420,38 +6464,6 @@ export type operations = {
}; };
}; };
}; };
/**
* List Board Images
* @description Gets a list of images for a board
*/
list_board_images: {
parameters: {
query?: {
/** @description The page offset */
offset?: number;
/** @description The number of boards per page */
limit?: number;
};
path: {
/** @description The id of the board */
board_id: string;
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
/** Get Version */ /** Get Version */
app_version: { app_version: {
responses: { responses: {

View File

@ -1,330 +0,0 @@
import { createAppAsyncThunk } from 'app/store/storeUtils';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
} from 'features/gallery/store/gallerySlice';
import { size } from 'lodash-es';
import queryString from 'query-string';
import { $client } from 'services/api/client';
import { paths } from 'services/api/schema';
type GetImageUrlsArg =
paths['/api/v1/images/{image_name}/urls']['get']['parameters']['path'];
type GetImageUrlsResponse =
paths['/api/v1/images/{image_name}/urls']['get']['responses']['200']['content']['application/json'];
type GetImageUrlsThunkConfig = {
rejectValue: {
arg: GetImageUrlsArg;
error: unknown;
};
};
/**
* Thunk to get image URLs
*/
export const imageUrlsReceived = createAppAsyncThunk<
GetImageUrlsResponse,
GetImageUrlsArg,
GetImageUrlsThunkConfig
>('thunkApi/imageUrlsReceived', async (arg, { rejectWithValue }) => {
const { image_name } = arg;
const { get } = $client.get();
const { data, error, response } = await get(
'/api/v1/images/{image_name}/urls',
{
params: {
path: {
image_name,
},
},
}
);
if (error) {
return rejectWithValue({ arg, error });
}
return data;
});
type GetImageMetadataArg =
paths['/api/v1/images/{image_name}']['get']['parameters']['path'];
type GetImageMetadataResponse =
paths['/api/v1/images/{image_name}']['get']['responses']['200']['content']['application/json'];
type GetImageMetadataThunkConfig = {
rejectValue: {
arg: GetImageMetadataArg;
error: unknown;
};
};
export const imageDTOReceived = createAppAsyncThunk<
GetImageMetadataResponse,
GetImageMetadataArg,
GetImageMetadataThunkConfig
>('thunkApi/imageMetadataReceived', async (arg, { rejectWithValue }) => {
const { image_name } = arg;
const { get } = $client.get();
const { data, error, response } = await get('/api/v1/images/{image_name}', {
params: {
path: { image_name },
},
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
});
type ControlNetAction = {
type: 'SET_CONTROLNET_IMAGE';
controlNetId: string;
};
type InitialImageAction = {
type: 'SET_INITIAL_IMAGE';
};
type NodesAction = {
type: 'SET_NODES_IMAGE';
nodeId: string;
fieldName: string;
};
type CanvasInitialImageAction = {
type: 'SET_CANVAS_INITIAL_IMAGE';
};
type CanvasMergedAction = {
type: 'TOAST_CANVAS_MERGED';
};
type CanvasSavedToGalleryAction = {
type: 'TOAST_CANVAS_SAVED_TO_GALLERY';
};
type UploadedToastAction = {
type: 'TOAST_UPLOADED';
};
type AddToBatchAction = {
type: 'ADD_TO_BATCH';
};
export type PostUploadAction =
| ControlNetAction
| InitialImageAction
| NodesAction
| CanvasInitialImageAction
| CanvasMergedAction
| CanvasSavedToGalleryAction
| UploadedToastAction
| AddToBatchAction;
type UploadImageArg =
paths['/api/v1/images/']['post']['parameters']['query'] & {
file: File;
postUploadAction?: PostUploadAction;
};
type UploadImageResponse =
paths['/api/v1/images/']['post']['responses']['201']['content']['application/json'];
type UploadImageThunkConfig = {
rejectValue: {
arg: UploadImageArg;
error: unknown;
};
};
/**
* `ImagesService.uploadImage()` thunk
*/
export const imageUploaded = createAppAsyncThunk<
UploadImageResponse,
UploadImageArg,
UploadImageThunkConfig
>('thunkApi/imageUploaded', async (arg, { rejectWithValue }) => {
const {
postUploadAction,
file,
image_category,
is_intermediate,
session_id,
} = arg;
const { post } = $client.get();
const { data, error, response } = await post('/api/v1/images/', {
params: {
query: {
image_category,
is_intermediate,
session_id,
},
},
body: { file },
bodySerializer: (body) => {
const formData = new FormData();
formData.append('file', body.file);
return formData;
},
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
});
type DeleteImageArg =
paths['/api/v1/images/{image_name}']['delete']['parameters']['path'];
type DeleteImageResponse =
paths['/api/v1/images/{image_name}']['delete']['responses']['200']['content']['application/json'];
type DeleteImageThunkConfig = {
rejectValue: {
arg: DeleteImageArg;
error: unknown;
};
};
/**
* `ImagesService.deleteImage()` thunk
*/
export const imageDeleted = createAppAsyncThunk<
DeleteImageResponse,
DeleteImageArg,
DeleteImageThunkConfig
>('thunkApi/imageDeleted', async (arg, { rejectWithValue }) => {
const { image_name } = arg;
const { del } = $client.get();
const { data, error, response } = await del('/api/v1/images/{image_name}', {
params: {
path: {
image_name,
},
},
});
if (error) {
return rejectWithValue({ arg, error });
}
});
type UpdateImageArg =
paths['/api/v1/images/{image_name}']['patch']['requestBody']['content']['application/json'] &
paths['/api/v1/images/{image_name}']['patch']['parameters']['path'];
type UpdateImageResponse =
paths['/api/v1/images/{image_name}']['patch']['responses']['200']['content']['application/json'];
type UpdateImageThunkConfig = {
rejectValue: {
arg: UpdateImageArg;
error: unknown;
};
};
/**
* `ImagesService.updateImage()` thunk
*/
export const imageUpdated = createAppAsyncThunk<
UpdateImageResponse,
UpdateImageArg,
UpdateImageThunkConfig
>('thunkApi/imageUpdated', async (arg, { rejectWithValue }) => {
const { image_name, image_category, is_intermediate, session_id } = arg;
const { patch } = $client.get();
const { data, error, response } = await patch('/api/v1/images/{image_name}', {
params: {
path: {
image_name,
},
},
body: {
image_category,
is_intermediate,
session_id,
},
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
});
export const IMAGES_PER_PAGE = 20;
const DEFAULT_IMAGES_LISTED_ARG = {
limit: IMAGES_PER_PAGE,
};
type ListImagesArg = NonNullable<
paths['/api/v1/images/']['get']['parameters']['query']
>;
type ListImagesResponse =
paths['/api/v1/images/']['get']['responses']['200']['content']['application/json'];
type ListImagesThunkConfig = {
rejectValue: {
arg: ListImagesArg;
error: unknown;
};
};
/**
* `ImagesService.listImagesWithMetadata()` thunk
*/
export const receivedPageOfImages = createAppAsyncThunk<
ListImagesResponse,
ListImagesArg,
ListImagesThunkConfig
>(
'thunkApi/receivedPageOfImages',
async (arg, { getState, rejectWithValue }) => {
const { get } = $client.get();
const state = getState();
const images = selectFilteredImages(state);
const categories =
state.gallery.galleryView === 'images'
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
let query: ListImagesArg = {};
if (size(arg)) {
query = {
...DEFAULT_IMAGES_LISTED_ARG,
offset: images.length,
...arg,
};
} else {
query = {
...DEFAULT_IMAGES_LISTED_ARG,
categories,
offset: images.length,
};
}
const { data, error, response } = await get('/api/v1/images/', {
params: {
query,
},
querySerializer: (q) => queryString.stringify(q, { arrayFormat: 'none' }),
});
if (error) {
return rejectWithValue({ arg, error });
}
return data;
}
);

View File

@ -1,3 +1,4 @@
import { UseToastOptions } from '@chakra-ui/react';
import { O } from 'ts-toolbelt'; import { O } from 'ts-toolbelt';
import { components } from './schema'; import { components } from './schema';
@ -186,3 +187,41 @@ export type CollectInvocationOutput =
export type LatentsOutput = components['schemas']['LatentsOutput']; export type LatentsOutput = components['schemas']['LatentsOutput'];
export type GraphInvocationOutput = export type GraphInvocationOutput =
components['schemas']['GraphInvocationOutput']; components['schemas']['GraphInvocationOutput'];
// Post-image upload actions, controls workflows when images are uploaded
export type ControlNetAction = {
type: 'SET_CONTROLNET_IMAGE';
controlNetId: string;
};
export type InitialImageAction = {
type: 'SET_INITIAL_IMAGE';
};
export type NodesAction = {
type: 'SET_NODES_IMAGE';
nodeId: string;
fieldName: string;
};
export type CanvasInitialImageAction = {
type: 'SET_CANVAS_INITIAL_IMAGE';
};
export type ToastAction = {
type: 'TOAST';
toastOptions?: UseToastOptions;
};
export type AddToBatchAction = {
type: 'ADD_TO_BATCH';
};
export type PostUploadAction =
| ControlNetAction
| InitialImageAction
| NodesAction
| CanvasInitialImageAction
| ToastAction
| AddToBatchAction;