Merge branch 'main' into release/invokeai-3-0-beta

This commit is contained in:
Lincoln Stein 2023-07-19 12:09:32 -04:00 committed by GitHub
commit 8439e30798
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 3307 additions and 2852 deletions

View File

@ -24,11 +24,14 @@ async def create_board_image(
):
"""Creates a board_image"""
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
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to add to board")
@board_images_router.delete(
"/",
operation_id="remove_board_image",
@ -43,27 +46,10 @@ async def remove_board_image(
):
"""Deletes a board_image"""
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
except Exception as e:
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 fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from invokeai.app.services.board_record_storage import BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO
from ..dependencies import ApiDependencies
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(
"/",
operation_id="create_board",
@ -69,25 +81,42 @@ async def 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(
board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query(
description="Permanently delete all images on the board", default=False
),
) -> None:
) -> DeleteBoardResult:
"""Deletes a board"""
try:
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(
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:
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)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=deleted_board_images,
deleted_images=[],
)
except Exception as e:
# TODO: Does this need any exception handling at all?
pass
raise HTTPException(status_code=500, detail="Failed to delete board")
@boards_router.get(
@ -115,3 +144,19 @@ async def list_boards(
status_code=400,
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

@ -84,6 +84,17 @@ async def delete_image(
# TODO: Does this need any exception handling at all?
pass
@images_router.post("/clear-intermediates", operation_id="clear_intermediates")
async def clear_intermediates() -> int:
"""Clears first 100 intermediates"""
try:
count_deleted = ApiDependencies.invoker.services.images.delete_many(is_intermediate=True)
return count_deleted
except Exception as e:
# TODO: Does this need any exception handling at all?
pass
@images_router.patch(
"/{image_name}",
@ -234,16 +245,16 @@ async def get_image_urls(
)
async def list_image_dtos(
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(
default=None, description="The categories of image to include"
default=None, description="The categories of image to include."
),
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(
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"),
limit: int = Query(default=10, description="The number of images per page"),

View File

@ -22,7 +22,7 @@ from ...backend.stable_diffusion.diffusers_pipeline import (
from ...backend.stable_diffusion.diffusion.shared_invokeai_diffusion import \
PostprocessingSettings
from ...backend.stable_diffusion.schedulers import SCHEDULER_MAP
from ...backend.util.devices import torch_dtype
from ...backend.util.devices import choose_torch_device, torch_dtype
from ..models.image import ImageCategory, ImageField, ResourceOrigin
from .baseinvocation import (BaseInvocation, BaseInvocationOutput,
InvocationConfig, InvocationContext)
@ -38,7 +38,6 @@ from diffusers.models.attention_processor import (
XFormersAttnProcessor,
)
class LatentsField(BaseModel):
"""A latents field used for passing latents between invocations"""

View File

@ -32,11 +32,11 @@ class BoardImageRecordStorageBase(ABC):
pass
@abstractmethod
def get_images_for_board(
def get_all_board_image_names_for_board(
self,
board_id: str,
) -> OffsetPaginatedResults[ImageRecord]:
"""Gets images for a board."""
) -> list[str]:
"""Gets all board images for a board, as a list of the image names."""
pass
@abstractmethod
@ -211,6 +211,26 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
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(
self,
image_name: str,

View File

@ -38,11 +38,11 @@ class BoardImagesServiceABC(ABC):
pass
@abstractmethod
def get_images_for_board(
def get_all_board_image_names_for_board(
self,
board_id: str,
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets images for a board."""
) -> list[str]:
"""Gets all board images for a board, as a list of the image names."""
pass
@abstractmethod
@ -98,30 +98,13 @@ class BoardImagesService(BoardImagesServiceABC):
) -> None:
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,
board_id: str,
) -> OffsetPaginatedResults[ImageDTO]:
image_records = self._services.board_image_records.get_images_for_board(
) -> list[str]:
return self._services.board_image_records.get_all_board_image_names_for_board(
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(
self,
@ -136,7 +119,7 @@ def board_record_to_dto(
) -> BoardDTO:
"""Converts a board record to a board DTO."""
return BoardDTO(
**board_record.dict(exclude={'cover_image_name'}),
**board_record.dict(exclude={"cover_image_name"}),
cover_image_name=cover_image_name,
image_count=image_count,
)

View File

@ -141,7 +141,7 @@ class EventServiceBase:
model_type=model_type,
submodel=submodel,
hash=model_info.hash,
location=model_info.location,
location=str(model_info.location),
precision=str(model_info.precision),
),
)

View File

@ -10,7 +10,10 @@ from pydantic.generics import GenericModel
from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.services.models.image_record import (
ImageRecord, ImageRecordChanges, deserialize_image_record)
ImageRecord,
ImageRecordChanges,
deserialize_image_record,
)
T = TypeVar("T", bound=BaseModel)
@ -97,8 +100,8 @@ class ImageRecordStorageBase(ABC):
@abstractmethod
def get_many(
self,
offset: int = 0,
limit: int = 10,
offset: Optional[int] = None,
limit: Optional[int] = None,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
@ -322,8 +325,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
def get_many(
self,
offset: int = 0,
limit: int = 10,
offset: Optional[int] = None,
limit: Optional[int] = None,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
@ -377,11 +380,15 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
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
AND board_images.board_id = ?
"""
query_params.append(board_id)
query_pagination = """--sql
@ -392,8 +399,12 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
images_query += query_conditions + query_pagination + ";"
# Add all the parameters
images_params = query_params.copy()
images_params.append(limit)
images_params.append(offset)
if limit is not None:
images_params.append(limit)
if offset is not None:
images_params.append(offset)
# Build the list of images, deserializing each row
self._cursor.execute(images_query, images_params)
result = cast(list[sqlite3.Row], self._cursor.fetchall())

View File

@ -11,7 +11,6 @@ from invokeai.app.models.image import (ImageCategory,
InvalidOriginException, ResourceOrigin)
from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase
from invokeai.app.services.graph import Graph
from invokeai.app.services.image_file_storage import (
ImageFileDeleteException, ImageFileNotFoundException,
ImageFileSaveException, ImageFileStorageBase)
@ -109,6 +108,13 @@ class ImageServiceABC(ABC):
"""Deletes an image."""
pass
@abstractmethod
def delete_many(self, is_intermediate: bool) -> int:
"""Deletes many images."""
pass
@abstractmethod
def delete_images_on_board(self, board_id: str):
"""Deletes all images on a board."""
@ -378,16 +384,39 @@ class ImageService(ImageServiceABC):
def delete_images_on_board(self, board_id: str):
try:
images = self._services.board_image_records.get_images_for_board(board_id)
image_name_list = list(
map(
lambda r: r.image_name,
images.items,
image_names = (
self._services.board_image_records.get_all_board_image_names_for_board(
board_id
)
)
for image_name in image_name_list:
for image_name in image_names:
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:
self._services.logger.error(f"Failed to delete image records")
raise
except ImageFileDeleteException:
self._services.logger.error(f"Failed to delete image files")
raise
except Exception as e:
self._services.logger.error("Problem deleting image records and files")
raise e
def delete_many(self, is_intermediate: bool):
try:
# only clears 100 at a time
images = self._services.image_records.get_many(offset=0, limit=100, is_intermediate=is_intermediate,)
count = len(images.items)
image_name_list = list(
map(
lambda r: r.image_name,
images.items,
)
)
for image_name in image_name_list:
self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list)
return count
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records")
raise

View File

@ -21,6 +21,7 @@ import re
import warnings
from pathlib import Path
from typing import Union
from packaging import version
import torch
from safetensors.torch import load_file
@ -63,6 +64,7 @@ from diffusers.pipelines.stable_diffusion.safety_checker import (
StableDiffusionSafetyChecker,
)
from diffusers.utils import is_safetensors_available
import transformers
from transformers import (
AutoFeatureExtractor,
BertTokenizerFast,
@ -841,7 +843,16 @@ def convert_ldm_clip_checkpoint(checkpoint):
key
]
text_model.load_state_dict(text_model_dict)
# transformers 4.31.0 and higher - this key no longer in state dict
if version.parse(transformers.__version__) >= version.parse("4.31.0"):
position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None)
text_model.load_state_dict(text_model_dict)
if position_ids is not None:
text_model.text_model.embeddings.position_ids.copy_(position_ids)
# transformers 4.30.2 and lower - position_ids is part of state_dict
else:
text_model.load_state_dict(text_model_dict)
return text_model
@ -947,7 +958,16 @@ def convert_open_clip_checkpoint(checkpoint):
text_model_dict[new_key] = checkpoint[key]
text_model.load_state_dict(text_model_dict)
# transformers 4.31.0 and higher - this key no longer in state dict
if version.parse(transformers.__version__) >= version.parse("4.31.0"):
position_ids = text_model_dict.pop("text_model.embeddings.position_ids", None)
text_model.load_state_dict(text_model_dict)
if position_ids is not None:
text_model.text_model.embeddings.position_ids.copy_(position_ids)
# transformers 4.30.2 and lower - position_ids is part of state_dict
else:
text_model.load_state_dict(text_model_dict)
return text_model

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
useDraggable as useOriginalDraggable,
useDroppable as useOriginalDroppable,
} from '@dnd-kit/core';
import { BoardId } from 'features/gallery/store/gallerySlice';
import { ImageDTO } from 'services/api/types';
type BaseDropData = {
@ -55,7 +56,7 @@ export type AddToBatchDropData = BaseDropData & {
export type MoveBoardDropData = BaseDropData & {
actionType: 'MOVE_BOARD';
context: { boardId: string | null };
context: { boardId: BoardId };
};
export type TypesafeDroppableData =
@ -158,8 +159,36 @@ export const isValidDrop = (
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD': {
// 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:
return false;
}

View File

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

View File

@ -1,7 +1,8 @@
import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
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 = {
isInitialImage: boolean;
@ -40,8 +41,7 @@ type Props = PropsWithChildren;
export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure();
const [addImageToBoard, result] = useAddImageToBoardMutation();
const dispatch = useAppDispatch();
// Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => {
@ -63,14 +63,16 @@ export const AddImageToBoardContextProvider = (props: Props) => {
const handleAddToBoard = useCallback(
(boardId: string) => {
if (imageToMove) {
addImageToBoard({
board_id: boardId,
image_name: imageToMove.image_name,
});
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO: imageToMove,
board_id: boardId,
})
);
closeAndClearImageToDelete();
}
},
[addImageToBoard, closeAndClearImageToDelete, imageToMove]
[dispatch, closeAndClearImageToDelete, imageToMove]
);
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 { addAppStartedListener } from './listeners/appStarted';
import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted';
import { addDeleteBoardAndImagesFulfilledListener } from './listeners/boardAndImagesDeleted';
import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard';
import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage';
import { addCanvasMergedListener } from './listeners/canvasMerged';
@ -29,10 +29,6 @@ import {
addRequestedImageDeletionListener,
} from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped';
import {
addImageMetadataReceivedFulfilledListener,
addImageMetadataReceivedRejectedListener,
} from './listeners/imageMetadataReceived';
import {
addImageRemovedFromBoardFulfilledListener,
addImageRemovedFromBoardRejectedListener,
@ -46,18 +42,10 @@ import {
addImageUploadedFulfilledListener,
addImageUploadedRejectedListener,
} from './listeners/imageUploaded';
import {
addImageUrlsReceivedFulfilledListener,
addImageUrlsReceivedRejectedListener,
} from './listeners/imageUrlsReceived';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addModelSelectedListener } from './listeners/modelSelected';
import { addModelsLoadedListener } from './listeners/modelsLoaded';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import {
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import {
addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener,
@ -91,6 +79,7 @@ import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextTo
import { addModelLoadStartedEventListener } from './listeners/socketio/socketModelLoadStarted';
import { addModelLoadCompletedEventListener } from './listeners/socketio/socketModelLoadCompleted';
import { addUpscaleRequestedListener } from './listeners/upscaleRequested';
import { addFirstListImagesListener } from './listeners/addFirstListImagesListener.ts';
export const listenerMiddleware = createListenerMiddleware();
@ -132,17 +121,9 @@ addRequestedImageDeletionListener();
addImageDeletedPendingListener();
addImageDeletedFulfilledListener();
addImageDeletedRejectedListener();
addRequestedBoardImageDeletionListener();
addDeleteBoardAndImagesFulfilledListener();
addImageToDeleteSelectedListener();
// Image metadata
addImageMetadataReceivedFulfilledListener();
addImageMetadataReceivedRejectedListener();
// Image URLs
addImageUrlsReceivedFulfilledListener();
addImageUrlsReceivedRejectedListener();
// User Invoked
addUserInvokedCanvasListener();
addUserInvokedNodesListener();
@ -198,17 +179,10 @@ addSessionCanceledPendingListener();
addSessionCanceledFulfilledListener();
addSessionCanceledRejectedListener();
// Fetching images
addReceivedPageOfImagesFulfilledListener();
addReceivedPageOfImagesRejectedListener();
// ControlNet
addControlNetImageProcessedListener();
addControlNetAutoProcessListener();
// Update image URLs on connect
// addUpdateImageUrlsOnConnectListener();
// Boards
addImageAddedToBoardFulfilledListener();
addImageAddedToBoardRejectedListener();
@ -229,5 +203,7 @@ addModelSelectedListener();
addAppStartedListener();
addModelsLoadedListener();
addAppConfigReceivedListener();
addFirstListImagesListener();
// Ad-hoc upscale workflwo
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 {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
isLoadingChanged,
} from 'features/gallery/store/gallerySlice';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted');
@ -17,29 +10,9 @@ export const addAppStartedListener = () => {
action,
{ getState, dispatch, unsubscribe, cancelActiveListeners }
) => {
// this should only run once
cancelActiveListeners();
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 { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected,
imageSelected,
selectImagesAll,
} from 'features/gallery/store/gallerySlice';
import { boardsApi } from 'services/api/endpoints/boards';
import {
IMAGES_PER_PAGE,
receivedPageOfImages,
} from 'services/api/thunks/image';
getBoardIdQueryParamForBoard,
getCategoriesQueryParamForBoard,
} from 'features/gallery/store/util';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
@ -19,54 +15,44 @@ const moduleLog = log.child({ namespace: 'boards' });
export const addBoardIdSelectedListener = () => {
startAppListening({
actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => {
const board_id = action.payload;
effect: async (
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 allImages = selectImagesAll(state);
const categories = getCategoriesQueryParamForBoard(_board_id);
const board_id = getBoardIdQueryParamForBoard(_board_id);
const queryArgs = { board_id, categories };
if (board_id === 'all') {
// Selected all images
dispatch(imageSelected(allImages[0]?.image_name ?? null));
return;
}
// wait until the board has some images - maybe it already has some from a previous fetch
// must use getState() to ensure we do not have stale state
const isSuccess = await condition(
() =>
imagesApi.endpoints.listImages.select(queryArgs)(getState())
.isSuccess,
1000
);
if (board_id === 'batch') {
// Selected the batch
dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null));
return;
}
if (isSuccess) {
// the board was just changed - we can select the first image
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(
queryArgs
)(getState());
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 (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 { addToast } from 'features/system/store/systemSlice';
import { imageUploaded } from 'services/api/thunks/image';
import { canvasMerged } from 'features/canvas/store/actions';
import { setMergedCanvas } from 'features/canvas/store/canvasSlice';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
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' });
@ -46,27 +46,28 @@ export const addCanvasMergedListener = () => {
});
const imageUploadedRequest = dispatch(
imageUploaded({
imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'mergedCanvas.png', {
type: 'image/png',
}),
image_category: 'general',
is_intermediate: true,
postUploadAction: {
type: 'TOAST_CANVAS_MERGED',
type: 'TOAST',
toastOptions: { title: 'Canvas Merged' },
},
})
);
const [{ payload }] = await take(
(
uploadedImageAction
): uploadedImageAction is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(uploadedImageAction) &&
(uploadedImageAction) =>
imagesApi.endpoints.uploadImage.matchFulfilled(uploadedImageAction) &&
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(
setMergedCanvas({
@ -76,13 +77,6 @@ export const addCanvasMergedListener = () => {
...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 { imageUploaded } from 'services/api/thunks/image';
import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
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' });
@ -28,28 +27,19 @@ export const addCanvasSavedToGalleryListener = () => {
return;
}
const imageUploadedRequest = dispatch(
imageUploaded({
dispatch(
imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'savedCanvas.png', {
type: 'image/png',
}),
image_category: 'general',
is_intermediate: false,
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 { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { sessionReadyToInvoke } from 'features/system/store/actions';
import { imagesApi } from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
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 { startAppListening } from '..';
@ -62,12 +62,13 @@ export const addControlNetImageProcessedListener = () => {
invocationCompleteAction.payload.data.result.image;
// Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take(
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
imageDTOReceived.fulfilled.match(action) &&
const [{ payload }] = await take(
(action) =>
imagesApi.endpoints.getImageDTO.matchFulfilled(action) &&
action.payload.image_name === image_name
);
const processedControlImage = imageMetadataReceivedAction.payload;
const processedControlImage = payload as ImageDTO;
moduleLog.debug(
{ data: { arg: action.payload, processedControlImage } },

View File

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

View File

@ -1,19 +1,17 @@
import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors';
import {
imageRemoved,
imageSelected,
} from 'features/gallery/store/gallerySlice';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
imageDeletionConfirmed,
isModalOpenChanged,
} from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { clamp } from 'lodash-es';
import { api } from 'services/api';
import { imageDeleted } from 'services/api/thunks/image';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' });
@ -36,10 +34,28 @@ export const addRequestedImageDeletionListener = () => {
state.gallery.selection[state.gallery.selection.length - 1];
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) {
dispatch(imageSelected(newSelectedImageId));
dispatch(imageSelected(newSelectedImageId as string));
} else {
dispatch(imageSelected(null));
}
@ -63,16 +79,15 @@ export const addRequestedImageDeletionListener = () => {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// 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
const wasImageDeleted = await condition(
(action): action is ReturnType<typeof imageDeleted.fulfilled> =>
imageDeleted.fulfilled.match(action) &&
(action) =>
imagesApi.endpoints.deleteImage.matchFulfilled(action) &&
action.meta.requestId === requestId,
30000
);
@ -91,7 +106,7 @@ export const addRequestedImageDeletionListener = () => {
*/
export const addImageDeletedPendingListener = () => {
startAppListening({
actionCreator: imageDeleted.pending,
matcher: imagesApi.endpoints.deleteImage.matchPending,
effect: (action, { dispatch, getState }) => {
//
},
@ -103,9 +118,12 @@ export const addImageDeletedPendingListener = () => {
*/
export const addImageDeletedFulfilledListener = () => {
startAppListening({
actionCreator: imageDeleted.fulfilled,
matcher: imagesApi.endpoints.deleteImage.matchFulfilled,
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 = () => {
startAppListening({
actionCreator: imageDeleted.rejected,
matcher: imagesApi.endpoints.deleteImage.matchRejected,
effect: (action, { dispatch, getState }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
{ data: { image: action.meta.arg.originalArgs } },
'Unable to delete image'
);
},

View File

@ -10,12 +10,9 @@ import {
imageSelected,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import {
fieldValueChanged,
imageCollectionFieldValueChanged,
} from 'features/nodes/store/nodesSlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' });
@ -137,23 +134,23 @@ export const addImageDroppedListener = () => {
return;
}
// set multiple nodes images (multiple images handler)
if (
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_NAMES'
) {
const { fieldName, nodeId } = overData.context;
dispatch(
imageCollectionFieldValueChanged({
nodeId,
fieldName,
value: activeData.payload.image_names.map((image_name) => ({
image_name,
})),
})
);
return;
}
// // set multiple nodes images (multiple images handler)
// if (
// overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
// activeData.payloadType === 'IMAGE_NAMES'
// ) {
// const { fieldName, nodeId } = overData.context;
// dispatch(
// imageCollectionFieldValueChanged({
// nodeId,
// fieldName,
// value: activeData.payload.image_names.map((image_name) => ({
// image_name,
// })),
// })
// );
// return;
// }
// add image to board
if (
@ -162,97 +159,95 @@ export const addImageDroppedListener = () => {
activeData.payload.imageDTO &&
overData.context.boardId
) {
const { image_name } = activeData.payload.imageDTO;
const { imageDTO } = activeData.payload;
const { boardId } = overData.context;
// if the board is "No Board", this is a remove action
if (boardId === 'no_board') {
dispatch(
imagesApi.endpoints.removeImageFromBoard.initiate({
imageDTO,
})
);
return;
}
// Handle adding image to batch
if (boardId === 'batch') {
// TODO
}
// Otherwise, add the image to the board
dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({
image_name,
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO,
board_id: boardId,
})
);
return;
}
// remove image from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO &&
overData.context.boardId === null
) {
const { image_name, board_id } = activeData.payload.imageDTO;
if (board_id) {
dispatch(
boardImagesApi.endpoints.removeImageFromBoard.initiate({
image_name,
board_id,
})
);
}
return;
}
// // add gallery selection to board
// if (
// overData.actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// overData.context.boardId
// ) {
// console.log('adding gallery selection to board');
// const board_id = overData.context.boardId;
// dispatch(
// boardImagesApi.endpoints.addManyBoardImages.initiate({
// board_id,
// image_names: activeData.payload.image_names,
// })
// );
// return;
// }
// add gallery selection to board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId
) {
console.log('adding gallery selection to board');
const board_id = overData.context.boardId;
dispatch(
boardImagesApi.endpoints.addManyBoardImages.initiate({
board_id,
image_names: activeData.payload.image_names,
})
);
return;
}
// // remove gallery selection from board
// if (
// overData.actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// overData.context.boardId === null
// ) {
// console.log('removing gallery selection to board');
// dispatch(
// boardImagesApi.endpoints.deleteManyBoardImages.initiate({
// image_names: activeData.payload.image_names,
// })
// );
// return;
// }
// remove gallery selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
console.log('removing gallery selection to board');
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
// // add batch selection to board
// if (
// overData.actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// overData.context.boardId
// ) {
// const board_id = overData.context.boardId;
// dispatch(
// boardImagesApi.endpoints.addManyBoardImages.initiate({
// board_id,
// image_names: activeData.payload.image_names,
// })
// );
// return;
// }
// add batch selection to board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId
) {
const board_id = overData.context.boardId;
dispatch(
boardImagesApi.endpoints.addManyBoardImages.initiate({
board_id,
image_names: activeData.payload.image_names,
})
);
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;
}
// // 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 { boardImagesApi } from 'services/api/endpoints/boardImages';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
export const addImageRemovedFromBoardFulfilledListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled,
matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
@ -20,7 +20,7 @@ export const addImageRemovedFromBoardFulfilledListener = () => {
export const addImageRemovedFromBoardRejectedListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected,
matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected,
effect: (action, { getState, dispatch }) => {
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 { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' });
export const addImageUpdatedFulfilledListener = () => {
startAppListening({
actionCreator: imageUpdated.fulfilled,
matcher: imagesApi.endpoints.updateImage.matchFulfilled,
effect: (action, { dispatch, getState }) => {
moduleLog.debug(
{ oldImage: action.meta.arg, updatedImage: action.payload },
{
data: {
oldImage: action.meta.arg.originalArgs,
updatedImage: action.payload,
},
},
'Image updated'
);
},
@ -18,9 +23,12 @@ export const addImageUpdatedFulfilledListener = () => {
export const addImageUpdatedRejectedListener = () => {
startAppListening({
actionCreator: imageUpdated.rejected,
matcher: imagesApi.endpoints.updateImage.matchRejected,
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 { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import {
imageUpserted,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
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 {
SYSTEM_BOARDS,
imagesApi,
} from '../../../../../services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'image' });
const DEFAULT_UPLOADED_TOAST: UseToastOptions = {
title: 'Image Uploaded',
status: 'success',
};
export const addImageUploadedFulfilledListener = () => {
startAppListening({
actionCreator: imageUploaded.fulfilled,
matcher: imagesApi.endpoints.uploadImage.matchFulfilled,
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) {
// No further actions needed for intermediate images
const { postUploadAction } = action.meta.arg.originalArgs;
if (
// No further actions needed for intermediate images,
action.payload.is_intermediate &&
// unless they have an explicit post-upload action
!postUploadAction
) {
return;
}
dispatch(imageUpserted(image));
// default action - just upload and alert user
if (postUploadAction?.type === 'TOAST') {
const { toastOptions } = postUploadAction;
if (SYSTEM_BOARDS.includes(selectedBoardId)) {
dispatch(addToast({ ...DEFAULT_UPLOADED_TOAST, ...toastOptions }));
} else {
// Add this image to the board
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
board_id: selectedBoardId,
imageDTO,
})
);
const { postUploadAction } = action.meta.arg;
// Attempt to get the board's name for the toast
const { data } = boardsApi.endpoints.listAllBoards.select()(state);
if (postUploadAction?.type === 'TOAST_CANVAS_SAVED_TO_GALLERY') {
dispatch(
addToast({ title: 'Canvas Saved to Gallery', status: 'success' })
);
return;
}
// 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}`;
if (postUploadAction?.type === 'TOAST_CANVAS_MERGED') {
dispatch(addToast({ title: 'Canvas Merged', status: 'success' }));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description,
})
);
}
return;
}
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;
}
@ -52,30 +90,49 @@ export const addImageUploadedFulfilledListener = () => {
dispatch(
controlNetImageChanged({
controlNetId,
controlImage: image.image_name,
controlImage: imageDTO.image_name,
})
);
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as control image',
})
);
return;
}
if (postUploadAction?.type === 'SET_INITIAL_IMAGE') {
dispatch(initialImageChanged(image));
dispatch(initialImageChanged(imageDTO));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Set as initial image',
})
);
return;
}
if (postUploadAction?.type === 'SET_NODES_IMAGE') {
const { nodeId, fieldName } = postUploadAction;
dispatch(fieldValueChanged({ nodeId, fieldName, value: image }));
return;
}
if (postUploadAction?.type === 'TOAST_UPLOADED') {
dispatch(addToast({ title: 'Image Uploaded', status: 'success' }));
dispatch(fieldValueChanged({ nodeId, fieldName, value: imageDTO }));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: `Set as node field ${fieldName}`,
})
);
return;
}
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;
}
},
@ -84,10 +141,10 @@ export const addImageUploadedFulfilledListener = () => {
export const addImageUploadedRejectedListener = () => {
startAppListening({
actionCreator: imageUploaded.rejected,
matcher: imagesApi.endpoints.uploadImage.matchRejected,
effect: (action, { dispatch }) => {
const { formData, ...rest } = action.meta.arg;
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } };
const { file, postUploadAction, ...rest } = action.meta.arg.originalArgs;
const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
moduleLog.error({ data: sanitizedData }, 'Image upload failed');
dispatch(
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 { selectImagesById } from 'features/gallery/store/gallerySlice';
import { isImageDTO } from 'services/api/guards';
import { initialImageSelected } from 'features/parameters/store/actions';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { t } from 'i18next';
import { startAppListening } from '..';
export const addInitialImageSelectedListener = () => {
startAppListening({
@ -20,25 +18,7 @@ export const addInitialImageSelectedListener = () => {
return;
}
if (isImageDTO(action.payload)) {
dispatch(initialImageChanged(action.payload));
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(initialImageChanged(action.payload));
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 { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import {
IMAGE_CATEGORIES,
boardIdSelected,
imageSelected,
} from 'features/gallery/store/gallerySlice';
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 { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session';
import {
appSocketInvocationComplete,
@ -22,7 +30,6 @@ export const addInvocationCompleteEventListener = () => {
{ data: action.payload },
`Invocation complete (${action.payload.data.node.type})`
);
const session_id = action.payload.data.graph_execution_state_id;
const { cancelType, isCancelScheduled, boardIdToAddTo } =
@ -39,33 +46,70 @@ export const addInvocationCompleteEventListener = () => {
// This complete event has an associated image output
if (isImageOutput(result) && !nodeDenylist.includes(node.type)) {
const { image_name } = result.image;
const { canvas, gallery } = getState();
// Get its metadata
dispatch(
imageDTOReceived({
image_name,
})
);
const imageDTO = await dispatch(
imagesApi.endpoints.getImageDTO.initiate(image_name)
).unwrap();
const [{ payload: imageDTO }] = await take(
imageDTOReceived.fulfilled.match
);
// Handle canvas image
// Add canvas images to the staging area
if (
graph_execution_state_id ===
getState().canvas.layerState.stagingArea.sessionId
graph_execution_state_id === canvas.layerState.stagingArea.sessionId
) {
dispatch(addImageToStagingArea(imageDTO));
}
if (boardIdToAddTo && !imageDTO.is_intermediate) {
if (!imageDTO.is_intermediate) {
// update the cache for 'All Images'
dispatch(
boardImagesApi.endpoints.addImageToBoard.initiate({
board_id: boardIdToAddTo,
image_name,
})
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,
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));

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,27 +32,57 @@ const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
>
<Flex
sx={{
opacity: 0.4,
width: '100%',
height: '100%',
flexDirection: 'column',
rowGap: 4,
position: 'absolute',
top: 0,
insetInlineStart: 0,
w: 'full',
h: 'full',
bg: 'base.700',
_dark: { bg: 'base.900' },
opacity: 0.7,
alignItems: 'center',
justifyContent: 'center',
bg: 'base.900',
boxShadow: `inset 0 0 20rem 1rem var(--invokeai-colors-${
isDragAccept ? 'accent' : 'error'
}-500)`,
transitionProperty: 'common',
transitionDuration: '0.1s',
}}
/>
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
width: 'full',
height: 'full',
alignItems: 'center',
justifyContent: 'center',
p: 4,
}}
>
{isDragAccept ? (
<Heading size="lg">Drop to Upload</Heading>
) : (
<>
<Heading size="lg">Invalid Upload</Heading>
<Heading size="md">Must be single JPEG or PNG image</Heading>
</>
)}
<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 ? (
<Heading size="lg">Drop to Upload</Heading>
) : (
<>
<Heading size="lg">Invalid Upload</Heading>
<Heading size="md">Must be single JPEG or PNG image</Heading>
</>
)}
</Flex>
</Flex>
</Box>
);

View File

@ -1,35 +1,43 @@
import { Box } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import useImageUploader from 'common/hooks/useImageUploader';
import { createSelector } from '@reduxjs/toolkit';
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 {
KeyboardEvent,
memo,
ReactNode,
memo,
useCallback,
useEffect,
useState,
} from 'react';
import { FileRejection, useDropzone } from 'react-dropzone';
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 { useAppToaster } from 'app/components/Toaster';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
import { AnimatePresence, motion } from 'framer-motion';
const selector = createSelector(
[systemSelector, activeTabNameSelector],
(system, activeTabName) => {
const { isConnected, isUploading } = system;
[activeTabNameSelector],
(activeTabName) => {
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 {
isUploaderDisabled,
activeTabName,
postUploadAction,
};
}
},
defaultSelectorOptions
);
type ImageUploaderProps = {
@ -38,12 +46,13 @@ type ImageUploaderProps = {
const ImageUploader = (props: ImageUploaderProps) => {
const { children } = props;
const dispatch = useAppDispatch();
const { isUploaderDisabled, activeTabName } = useAppSelector(selector);
const { postUploadAction } = useAppSelector(selector);
const isBusy = useAppSelector(selectIsBusy);
const toaster = useAppToaster();
const { t } = useTranslation();
const [isHandlingUpload, setIsHandlingUpload] = useState<boolean>(false);
const { setOpenUploaderFunction } = useImageUploader();
const [uploadImage] = useUploadImageMutation();
const fileRejectionCallback = useCallback(
(rejection: FileRejection) => {
@ -60,16 +69,14 @@ const ImageUploader = (props: ImageUploaderProps) => {
const fileAcceptedCallback = useCallback(
async (file: File) => {
dispatch(
imageUploaded({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction: { type: 'TOAST_UPLOADED' },
})
);
uploadImage({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction,
});
},
[dispatch]
[postUploadAction, uploadImage]
);
const onDrop = useCallback(
@ -101,13 +108,12 @@ const ImageUploader = (props: ImageUploaderProps) => {
isDragReject,
isDragActive,
inputRef,
open,
} = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true,
onDrop,
onDragOver: () => setIsHandlingUpload(true),
disabled: isUploaderDisabled,
disabled: isBusy,
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
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
setOpenUploaderFunction(() => {
return;
});
};
}, [inputRef, open, setOpenUploaderFunction]);
}, [inputRef]);
return (
<Box
@ -150,13 +150,30 @@ const ImageUploader = (props: ImageUploaderProps) => {
>
<input {...getInputProps()} />
{children}
{isDragActive && isHandlingUpload && (
<ImageUploadOverlay
isDragAccept={isDragAccept}
isDragReject={isDragReject}
setIsHandlingUpload={setIsHandlingUpload}
/>
)}
<AnimatePresence>
{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
isDragAccept={isDragAccept}
isDragReject={isDragReject}
setIsHandlingUpload={setIsHandlingUpload}
/>
</motion.div>
)}
</AnimatePresence>
</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 { 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 = {
postUploadAction?: PostUploadAction;
@ -12,7 +12,7 @@ type UseImageUploadButtonArgs = {
* Provides image uploader functionality to any component.
*
* @example
* const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
* const { getUploadButtonProps, getUploadInputProps, openUploader } = useImageUploadButton({
* postUploadAction: {
* type: 'SET_CONTROLNET_IMAGE',
* controlNetId: '12345',
@ -20,6 +20,9 @@ type UseImageUploadButtonArgs = {
* isDisabled: getIsUploadDisabled(),
* });
*
* // open the uploaded directly
* const handleSomething = () => { openUploader() }
*
* // in the render function
* <Button {...getUploadButtonProps()} /> // will open the file dialog on click
* <input {...getUploadInputProps()} /> // hidden, handles native upload functionality
@ -28,24 +31,23 @@ export const useImageUploadButton = ({
postUploadAction,
isDisabled,
}: UseImageUploadButtonArgs) => {
const dispatch = useAppDispatch();
const [uploadImage] = useUploadImageMutation();
const onDropAccepted = useCallback(
(files: File[]) => {
const file = files[0];
if (!file) {
return;
}
dispatch(
imageUploaded({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction,
})
);
uploadImage({
file,
image_category: 'user',
is_intermediate: false,
postUploadAction: postUploadAction ?? { type: 'TOAST' },
});
},
[dispatch, postUploadAction]
[postUploadAction, uploadImage]
);
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,
} from 'react-icons/fa';
import { stagingAreaImageSaved } from '../store/actions';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
const selector = createSelector(
[canvasSelector],
@ -123,6 +125,10 @@ const IAICanvasStagingAreaToolbar = () => {
[dispatch, sessionId]
);
const { data: imageDTO } = useGetImageDTOQuery(
currentStagingAreaImage?.imageName ?? skipToken
);
if (!currentStagingAreaImage) return null;
return (
@ -173,14 +179,19 @@ const IAICanvasStagingAreaToolbar = () => {
<IAIIconButton
tooltip={t('unifiedCanvas.saveToGallery')}
aria-label={t('unifiedCanvas.saveToGallery')}
isDisabled={!imageDTO || !imageDTO.is_intermediate}
icon={<FaSave />}
onClick={() =>
onClick={() => {
if (!imageDTO) {
return;
}
dispatch(
stagingAreaImageSaved({
imageName: currentStagingAreaImage.imageName,
imageDTO,
})
)
}
);
}}
colorScheme="accent"
/>
<IAIIconButton

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas';
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 { appSocketInvocationError } from 'services/events/actions';
import { controlNetImageProcessed } from './actions';
@ -300,21 +300,6 @@ export const controlNetSlice = createSlice({
}
});
builder.addCase(imageDeleted.pending, (state, action) => {
// Preemptively remove the image from all controlnets
// TODO: doesn't the imageusage stuff do this for us?
const { image_name } = action.meta.arg;
forEach(state.controlNets, (c) => {
if (c.controlImage === image_name) {
c.controlImage = null;
c.processedControlImage = null;
}
if (c.processedControlImage === image_name) {
c.processedControlImage = null;
}
});
});
builder.addCase(appSocketInvocationError, (state, action) => {
state.pendingControlImages = [];
});
@ -322,6 +307,24 @@ export const controlNetSlice = createSlice({
builder.addMatcher(isAnySessionRejected, (state, action) => {
state.pendingControlImages = [];
});
builder.addMatcher(
imagesApi.endpoints.deleteImage.matchFulfilled,
(state, action) => {
// Preemptively remove the image from all controlnets
// TODO: doesn't the imageusage stuff do this for us?
const { image_name } = action.meta.arg.originalArgs;
forEach(state.controlNets, (c) => {
if (c.controlImage === image_name) {
c.controlImage = null;
c.processedControlImage = null;
}
if (c.processedControlImage === image_name) {
c.processedControlImage = null;
}
});
}
);
},
});

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 { boardIdSelected } from 'features/gallery/store/gallerySlice';
import {
IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { FaImages } 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: IMAGE_CATEGORIES,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleAllImagesBoardClick = () => {
dispatch(boardIdSelected('all'));
const handleClick = () => {
dispatch(boardIdSelected('images'));
};
const droppableData: MoveBoardDropData = {
id: 'all-images-board',
actionType: 'MOVE_BOARD',
context: { boardId: null },
};
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: 'images' },
// };
return (
<GenericBoard
droppableData={droppableData}
onClick={handleAllImagesBoardClick}
onClick={handleClick}
isSelected={isSelected}
icon={FaImages}
label="All Images"
badgeCount={total}
/>
);
};

View File

@ -1,27 +1,27 @@
import { CloseIcon } from '@chakra-ui/icons';
import {
Collapse,
Flex,
Grid,
GridItem,
IconButton,
Input,
InputGroup,
InputRightElement,
useDisclosure,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
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 { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
import AddBoardButton from './AddBoardButton';
import AllAssetsBoard from './AllAssetsBoard';
import AllImagesBoard from './AllImagesBoard';
import BatchBoard from './BatchBoard';
import BoardsSearch from './BoardsSearch';
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(
[stateSelector],
@ -39,110 +39,91 @@ type Props = {
const BoardsList = (props: Props) => {
const { isOpen } = props;
const dispatch = useAppDispatch();
const { selectedBoardId, searchText } = useAppSelector(selector);
const { data: boards } = useListAllBoardsQuery();
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
const filteredBoards = searchText
? boards?.filter((board) =>
board.board_name.toLowerCase().includes(searchText.toLowerCase())
)
: boards;
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
const [searchMode, setSearchMode] = useState(false);
const handleBoardSearch = (searchTerm: string) => {
setSearchMode(searchTerm.length > 0);
dispatch(setBoardSearchText(searchTerm));
};
const clearBoardSearch = () => {
setSearchMode(false);
dispatch(setBoardSearchText(''));
};
return (
<Collapse in={isOpen} animateOpacity>
<Flex
layerStyle={'first'}
sx={{
flexDir: 'column',
gap: 2,
p: 2,
mt: 2,
borderRadius: 'base',
}}
>
<Flex sx={{ gap: 2, alignItems: 'center' }}>
<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>
<AddBoardButton />
</Flex>
<OverlayScrollbarsComponent
defer
style={{ height: '100%', width: '100%' }}
options={{
scrollbars: {
visibility: 'auto',
autoHide: 'move',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
<>
<Collapse in={isOpen} animateOpacity>
<Flex
layerStyle={'first'}
sx={{
flexDir: 'column',
gap: 2,
p: 2,
mt: 2,
borderRadius: 'base',
}}
>
<Grid
className="list-container"
sx={{
gridTemplateRows: '6.5rem 6.5rem',
gridAutoFlow: 'column dense',
gridAutoColumns: '5rem',
<Flex sx={{ gap: 2, alignItems: 'center' }}>
<BoardsSearch setSearchMode={setSearchMode} />
<AddBoardButton />
</Flex>
<OverlayScrollbarsComponent
defer
style={{ height: '100%', width: '100%' }}
options={{
scrollbars: {
visibility: 'auto',
autoHide: 'move',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
}}
>
{!searchMode && (
<>
<GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={selectedBoardId === 'all'} />
</GridItem>
{isBatchEnabled && (
<Grid
className="list-container"
sx={{
gridTemplateRows: '6.5rem 6.5rem',
gridAutoFlow: 'column dense',
gridAutoColumns: '5rem',
}}
>
{!searchMode && (
<>
<GridItem sx={{ p: 1.5 }}>
<BatchBoard isSelected={selectedBoardId === 'batch'} />
<AllImagesBoard isSelected={selectedBoardId === 'images'} />
</GridItem>
)}
</>
)}
{filteredBoards &&
filteredBoards.map((board) => (
<GridItem key={board.board_id} sx={{ p: 1.5 }}>
<GalleryBoard
board={board}
isSelected={selectedBoardId === board.board_id}
/>
</GridItem>
))}
</Grid>
</OverlayScrollbarsComponent>
</Flex>
</Collapse>
<GridItem sx={{ p: 1.5 }}>
<AllAssetsBoard isSelected={selectedBoardId === 'assets'} />
</GridItem>
<GridItem sx={{ p: 1.5 }}>
<NoBoardBoard isSelected={selectedBoardId === 'no_board'} />
</GridItem>
{isBatchEnabled && (
<GridItem sx={{ p: 1.5 }}>
<BatchBoard isSelected={selectedBoardId === 'batch'} />
</GridItem>
)}
</>
)}
{filteredBoards &&
filteredBoards.map((board) => (
<GridItem key={board.board_id} sx={{ p: 1.5 }}>
<GalleryBoard
board={board}
isSelected={selectedBoardId === board.board_id}
setBoardToDelete={setBoardToDelete}
/>
</GridItem>
))}
</Grid>
</OverlayScrollbarsComponent>
</Flex>
</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,217 +8,208 @@ import {
Image,
MenuItem,
MenuList,
Text,
useColorMode,
} 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 { 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 { 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 { DeleteBoardImagesContext } from '../../../../../app/contexts/DeleteBoardImagesContext';
interface GalleryBoardProps {
board: BoardDTO;
isSelected: boolean;
setBoardToDelete: (board?: BoardDTO) => void;
}
const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
const dispatch = useAppDispatch();
const GalleryBoard = memo(
({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => {
const dispatch = useAppDispatch();
const { currentData: coverImage } = useGetImageDTOQuery(
board.cover_image_name ?? skipToken
);
const { currentData: coverImage } = useGetImageDTOQuery(
board.cover_image_name ?? skipToken
);
const { colorMode } = useColorMode();
const { colorMode } = useColorMode();
const { board_name, board_id } = board;
const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(board_id));
}, [board_id, dispatch]);
const { board_name, board_id } = board;
const [updateBoard, { isLoading: isUpdateBoardLoading }] =
useUpdateBoardMutation();
const { onClickDeleteBoardImages } = useContext(DeleteBoardImagesContext);
const handleUpdateBoardName = (newBoardName: string) => {
updateBoard({ board_id, changes: { board_name: newBoardName } });
};
const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(board_id));
}, [board_id, dispatch]);
const handleDeleteBoard = useCallback(() => {
setBoardToDelete(board);
}, [board, setBoardToDelete]);
const [updateBoard, { isLoading: isUpdateBoardLoading }] =
useUpdateBoardMutation();
const droppableData: MoveBoardDropData = useMemo(
() => ({
id: board_id,
actionType: 'MOVE_BOARD',
context: { boardId: board_id },
}),
[board_id]
);
const [deleteBoard, { isLoading: isDeleteBoardLoading }] =
useDeleteBoardMutation();
const handleUpdateBoardName = (newBoardName: string) => {
updateBoard({ board_id, changes: { board_name: newBoardName } });
};
const handleDeleteBoard = useCallback(() => {
deleteBoard(board_id);
}, [board_id, deleteBoard]);
const handleAddBoardToBatch = useCallback(() => {
// dispatch(boardAddedToBatch({ board_id }));
}, []);
const handleDeleteBoardAndImages = useCallback(() => {
onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]);
const droppableData: MoveBoardDropData = useMemo(
() => ({
id: board_id,
actionType: 'MOVE_BOARD',
context: { boardId: board_id },
}),
[board_id]
);
return (
<Box sx={{ touchAction: 'none', height: 'full' }}>
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
{board.image_count > 0 && (
<>
<MenuItem
isDisabled={!board.image_count}
icon={<FaImages />}
onClickCapture={handleAddBoardToBatch}
>
Add Board to Batch
</MenuItem>
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
</>
)}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoard}
return (
<Box sx={{ touchAction: 'none', height: 'full' }}>
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
menuButtonProps={{
bg: 'transparent',
_hover: { bg: 'transparent' },
}}
renderMenu={() => (
<MenuList
sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps}
>
Delete Board
</MenuItem>
</MenuList>
)}
>
{(ref) => (
<Flex
key={board_id}
userSelect="none"
ref={ref}
sx={{
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
w: 'full',
h: 'full',
}}
>
{board.image_count > 0 && (
<>
{/* <MenuItem
isDisabled={!board.image_count}
icon={<FaImages />}
onClickCapture={handleAddBoardToBatch}
>
Add Board to Batch
</MenuItem> */}
</>
)}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoard}
>
Delete Board
</MenuItem>
</MenuList>
)}
>
{(ref) => (
<Flex
onClick={handleSelectBoard}
key={board_id}
userSelect="none"
ref={ref}
sx={{
position: 'relative',
justifyContent: 'center',
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
borderRadius: 'base',
cursor: 'pointer',
w: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
shadow: isSelected ? 'selected.light' : undefined,
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
flexShrink: 0,
h: 'full',
}}
>
{board.cover_image_name && coverImage?.image_url && (
<Image src={coverImage?.image_url} draggable={false} />
)}
{!(board.cover_image_name && coverImage?.image_url) && (
<IAINoContentFallback
boxSize={8}
icon={FaFolder}
<Flex
onClick={handleSelectBoard}
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
w: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
shadow: isSelected ? 'selected.light' : undefined,
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
flexShrink: 0,
}}
>
{board.cover_image_name && coverImage?.thumbnail_url && (
<Image src={coverImage?.thumbnail_url} draggable={false} />
)}
{!(board.cover_image_name && coverImage?.thumbnail_url) && (
<IAINoContentFallback
boxSize={8}
icon={FaUser}
sx={{
borderWidth: '2px',
borderStyle: 'solid',
borderColor: 'base.200',
_dark: {
borderColor: 'base.800',
},
}}
/>
)}
<Flex
sx={{
border: '2px solid var(--invokeai-colors-base-200)',
_dark: {
border: '2px solid var(--invokeai-colors-base-800)',
},
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
}}
>
<Badge variant="solid">{board.image_count}</Badge>
</Flex>
<IAIDroppable
data={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
/>
)}
</Flex>
<Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
width: 'full',
height: 'full',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Badge variant="solid">{board.image_count}</Badge>
<Editable
defaultValue={board_name}
submitOnBlur={false}
onSubmit={(nextValue) => {
handleUpdateBoardName(nextValue);
}}
sx={{ maxW: 'full' }}
>
<EditablePreview
sx={{
color: isSelected
? mode('base.900', 'base.50')(colorMode)
: mode('base.700', 'base.200')(colorMode),
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
textAlign: 'center',
p: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
noOfLines={1}
/>
<EditableInput
sx={{
color: mode('base.900', 'base.50')(colorMode),
fontSize: 'xs',
borderColor: mode('base.500', 'base.500')(colorMode),
p: 0,
outline: 0,
}}
/>
</Editable>
</Flex>
<IAIDroppable data={droppableData} />
</Flex>
<Flex
sx={{
width: 'full',
height: 'full',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Editable
defaultValue={board_name}
submitOnBlur={false}
onSubmit={(nextValue) => {
handleUpdateBoardName(nextValue);
}}
>
<EditablePreview
sx={{
color: isSelected
? mode('base.900', 'base.50')(colorMode)
: mode('base.700', 'base.200')(colorMode),
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
textAlign: 'center',
p: 0,
}}
noOfLines={1}
/>
<EditableInput
sx={{
color: mode('base.900', 'base.50')(colorMode),
fontSize: 'xs',
borderColor: mode('base.500', 'base.500')(colorMode),
p: 0,
outline: 0,
}}
/>
</Editable>
</Flex>
</Flex>
)}
</ContextMenu>
</Box>
);
});
)}
</ContextMenu>
</Box>
);
}
);
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 IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { ReactNode } from 'react';
type GenericBoardProps = {
droppableData: TypesafeDroppableData;
droppableData?: TypesafeDroppableData;
onClick: () => void;
isSelected: boolean;
icon: As;
label: string;
dropLabel?: ReactNode;
badgeCount?: number;
};
const formatBadgeCount = (count: number) =>
Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(count);
const GenericBoard = (props: GenericBoardProps) => {
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props;
const {
droppableData,
onClick,
isSelected,
icon,
label,
badgeCount,
dropLabel,
} = props;
return (
<Flex
@ -59,10 +75,10 @@ const GenericBoard = (props: GenericBoardProps) => {
}}
>
{badgeCount !== undefined && (
<Badge variant="solid">{badgeCount}</Badge>
<Badge variant="solid">{formatBadgeCount(badgeCount)}</Badge>
)}
</Flex>
<IAIDroppable data={droppableData} />
<IAIDroppable data={droppableData} dropLabel={dropLabel} />
</Flex>
<Flex
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 ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from '../NextPrevImageButtons';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { FaImage } from 'react-icons/fa';
export const imagesSelector = createSelector(
[stateSelector, selectLastSelectedImage],
@ -168,7 +170,11 @@ const CurrentImagePreview = () => {
draggableData={draggableData}
isUploadDisabled={true}
fitContainer
useThumbailFallback
dropLabel="Set as Current Image"
noContentFallback={
<IAINoContentFallback icon={FaImage} label="No image selected" />
}
/>
)}
{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 { 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 { MouseEvent, memo, useCallback, useMemo } from 'react';
import { MouseEvent, memo, useCallback } from 'react';
import { ImageDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu';
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
import SingleSelectionMenuItems from './SingleSelectionMenuItems';
type Props = {
@ -16,23 +11,23 @@ type Props = {
};
const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selector = useMemo(
() =>
createSelector(
[stateSelector],
({ gallery }) => {
const selectionCount = gallery.selection.length;
// const selector = useMemo(
// () =>
// createSelector(
// [stateSelector],
// ({ gallery }) => {
// const selectionCount = gallery.selection.length;
return { selectionCount };
},
defaultSelectorOptions
),
[]
);
// return { selectionCount };
// },
// defaultSelectorOptions
// ),
// []
// );
const { selectionCount } = useAppSelector(selector);
// const { selectionCount } = useAppSelector(selector);
const handleContextMenu = useCallback((e: MouseEvent<HTMLDivElement>) => {
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
@ -48,13 +43,9 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
<MenuList
sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps}
onContextMenu={handleContextMenu}
onContextMenu={skipEvent}
>
{selectionCount === 1 ? (
<SingleSelectionMenuItems imageDTO={imageDTO} />
) : (
<MultipleSelectionMenuItems />
)}
<SingleSelectionMenuItems imageDTO={imageDTO} />
</MenuList>
) : null
}

View File

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

View File

@ -1,113 +1,34 @@
import {
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 { Box, Flex, VStack, useDisclosure } 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 { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode';
import { memo, useRef } from 'react';
import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
import GalleryPinButton from './GalleryPinButton';
import GallerySettingsPopover from './GallerySettingsPopover';
import BatchImageGrid from './ImageGrid/BatchImageGrid';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
const selector = createSelector(
[stateSelector],
(state) => {
const {
selectedBoardId,
galleryImageMinimumWidth,
galleryView,
shouldAutoSwitch,
} = state.gallery;
const { shouldPinGallery } = state.ui;
const { selectedBoardId } = state.gallery;
return {
selectedBoardId,
shouldPinGallery,
galleryImageMinimumWidth,
shouldAutoSwitch,
galleryView,
};
},
defaultSelectorOptions
);
const ImageGalleryContent = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null);
const { colorMode } = useColorMode();
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]);
const { selectedBoardId } = useAppSelector(selector);
const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } =
useDisclosure();
return (
<VStack
@ -127,95 +48,12 @@ const ImageGalleryContent = () => {
gap: 2,
}}
>
<ButtonGroup isAttached>
<IAIIconButton
tooltip={t('gallery.images')}
aria-label={t('gallery.images')}
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 />}
<GallerySettingsPopover />
<GalleryBoardName
isOpen={isBoardListOpen}
onToggle={onToggleBoardList}
/>
<GalleryPinButton />
</Flex>
<Box>
<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 { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import {
imageRangeEndSelected,
imageSelected,
imageSelectionToggled,
} from 'features/gallery/store/gallerySlice';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@ -84,7 +81,7 @@ const GalleryImage = (props: HoverableImageProps) => {
}, [imageDTO, selection, selectionCount]);
if (!imageDTO) {
return <Spinner />;
return <IAIFillSkeleton />;
}
return (

View File

@ -1,124 +1,70 @@
import { Box } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Box, Spinner } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
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 { useTranslation } from 'react-i18next';
import { 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 { FaExclamationCircle, FaImage } from 'react-icons/fa';
import { VirtuosoGrid } from 'react-virtuoso';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { useListBoardImagesQuery } from '../../../../services/api/endpoints/boardImages';
import {
useLazyListImagesQuery,
useListImagesQuery,
} from 'services/api/endpoints/images';
import GalleryImage from './GalleryImage';
import ImageGridItemContainer from './ImageGridItemContainer';
import ImageGridListContainer from './ImageGridListContainer';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
const selector = createSelector(
[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,
};
const overlayScrollbarsConfig: UseOverlayScrollbarsParams = {
defer: true,
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'leave',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
overflow: { x: 'hidden' },
},
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,
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'leave',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
overflow: { x: 'hidden' },
},
});
const [initialize, osInstance] = useOverlayScrollbars(
overlayScrollbarsConfig
);
const [didInitialFetch, setDidInitialFetch] = useState(false);
const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const dispatch = useAppDispatch();
const { currentData, isFetching, isSuccess, isError } =
useListImagesQuery(queryArgs);
const {
galleryImageMinimumWidth,
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(() => {
if (selectedBoardId === 'all') {
return imageNamesAll; // already sorted by images/uploads in gallery selector
} else {
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const imageList = (imagesForBoard?.items || []).filter((img) =>
categories.includes(img.image_category)
);
return imageList.map((img) => img.image_name);
}
}, [selectedBoardId, galleryView, imagesForBoard, imageNamesAll]);
const [listImages] = useLazyListImagesQuery();
const areMoreAvailable = useMemo(() => {
return selectedBoardId === 'all' ? totalAll > imageNamesAll.length : false;
}, [selectedBoardId, imageNamesAll.length, totalAll]);
const isLoading = useMemo(() => {
return selectedBoardId === 'all' ? isLoadingAll : isLoadingImagesForBoard;
}, [selectedBoardId, isLoadingAll, isLoadingImagesForBoard]);
if (!currentData) {
return false;
}
return currentData.ids.length < currentData.total;
}, [currentData]);
const handleLoadMoreImages = useCallback(() => {
dispatch(
receivedPageOfImages({
categories:
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
is_intermediate: false,
offset: imageNames.length,
limit: IMAGE_LIMIT,
})
);
}, [dispatch, imageNames.length, galleryView]);
listImages({
...queryArgs,
offset: currentData?.ids.length ?? 0,
limit: IMAGE_LIMIT,
});
}, [listImages, queryArgs, currentData?.ids.length]);
useEffect(() => {
// Set up gallery scroler
// Initialize the gallery's custom scrollbar
const { current: root } = rootRef;
if (scroller && root) {
initialize({
@ -131,47 +77,17 @@ const GalleryImageGrid = () => {
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
const handleEndReached = useMemo(() => {
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) {
if (!currentData) {
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
label={t('gallery.noImagesInGallery')}
icon={FaImage}
@ -180,27 +96,28 @@ const GalleryImageGrid = () => {
);
}
if (status !== 'rejected') {
if (isSuccess && currentData) {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={imageNames}
data={currentData.ids}
endReached={handleLoadMoreImages}
components={{
Item: ImageGridItemContainer,
List: ImageGridListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, imageName) => (
<GalleryImage key={imageName} imageName={imageName} />
<GalleryImage key={imageName} imageName={imageName as string} />
)}
/>
</Box>
<IAIButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isLoading={status === 'pending'}
isLoading={isFetching}
loadingText="Loading"
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);

View File

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

View File

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

View File

@ -1,136 +1,38 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { clamp, keyBy } from 'lodash-es';
import { ImageDTO } from 'services/api/types';
import { ListImagesArgs } from 'services/api/endpoints/images';
import { INITIAL_IMAGE_LIMIT } from './gallerySlice';
import {
ASSETS_CATEGORIES,
BoardId,
IMAGE_CATEGORIES,
imagesAdapter,
initialGalleryState,
} from './gallerySlice';
getBoardIdQueryParamForBoard,
getCategoriesQueryParamForBoard,
} from './util';
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(
(state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions
);
export const selectSelectedImages = createSelector(
(state: RootState) => state,
(state) =>
imagesAdapter
.getSelectors()
.selectAll(state.gallery)
.filter((i) => state.gallery.selection.includes(i.image_name)),
defaultSelectorOptions
);
export const selectListImagesBaseQueryArgs = createSelector(
[(state: RootState) => state],
(state) => {
const { selectedBoardId } = state.gallery;
export const selectNextImageToSelectLocal = createSelector(
[
(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 categories = getCategoriesQueryParamForBoard(selectedBoardId);
const board_id = getBoardIdQueryParamForBoard(selectedBoardId);
const deletedImageIndex = ids.findIndex(
(result) => result.toString() === image_name
);
const listImagesBaseQueryArgs: ListImagesArgs = {
categories,
board_id,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
const filteredIds = ids.filter((id) => id.toString() !== image_name);
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);
return listImagesBaseQueryArgs;
},
defaultSelectorOptions
);

View File

@ -1,20 +1,8 @@
import type { PayloadAction, Update } from '@reduxjs/toolkit';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { dateComparator } from 'common/util/dateComparator';
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { uniq } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards';
import {
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),
});
import { ImageCategory } from 'services/api/types';
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = [
@ -26,113 +14,74 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20;
export type GalleryView = 'images' | 'assets';
// export type GalleryView = 'images' | 'assets';
export type BoardId =
| 'all'
| 'none'
| 'images'
| 'assets'
| 'no_board'
| 'batch'
| (string & Record<never, never>);
type AdditionaGalleryState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
isFetching: boolean;
type GalleryState = {
selection: string[];
shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number;
galleryView: GalleryView;
selectedBoardId: BoardId;
isInitialized: boolean;
batchImageNames: string[];
isBatchEnabled: boolean;
};
export const initialGalleryState =
imagesAdapter.getInitialState<AdditionaGalleryState>({
offset: 0,
limit: 0,
total: 0,
isLoading: true,
isFetching: true,
selection: [],
shouldAutoSwitch: true,
galleryImageMinimumWidth: 96,
galleryView: 'images',
selectedBoardId: 'all',
isInitialized: false,
batchImageNames: [],
isBatchEnabled: false,
});
export const initialGalleryState: GalleryState = {
selection: [],
shouldAutoSwitch: true,
galleryImageMinimumWidth: 96,
selectedBoardId: 'images',
batchImageNames: [],
isBatchEnabled: false,
};
export const gallerySlice = createSlice({
name: 'gallery',
initialState: initialGalleryState,
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[]>) => {
imagesAdapter.removeMany(state, action.payload);
state.batchImageNames = state.batchImageNames.filter(
(name) => !action.payload.includes(name)
);
// TODO: port all instances of this to use RTK Query cache
// imagesAdapter.removeMany(state, action.payload);
// state.batchImageNames = state.batchImageNames.filter(
// (name) => !action.payload.includes(name)
// );
},
imageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1];
const filteredImages = selectFilteredImagesLocal(state);
const lastClickedIndex = filteredImages.findIndex(
(n) => n.image_name === lastSelectedImage
);
const currentClickedIndex = filteredImages.findIndex(
(n) => n.image_name === rangeEndImageName
);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = filteredImages
.slice(start, end + 1)
.map((i) => i.image_name);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
// const rangeEndImageName = action.payload;
// const lastSelectedImage = state.selection[state.selection.length - 1];
// const filteredImages = selectFilteredImagesLocal(state);
// const lastClickedIndex = filteredImages.findIndex(
// (n) => n.image_name === lastSelectedImage
// );
// const currentClickedIndex = filteredImages.findIndex(
// (n) => n.image_name === rangeEndImageName
// );
// if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// // We have a valid range!
// const start = Math.min(lastClickedIndex, currentClickedIndex);
// const end = Math.max(lastClickedIndex, currentClickedIndex);
// const imagesToSelect = filteredImages
// .slice(start, end + 1)
// .map((i) => i.image_name);
// state.selection = uniq(state.selection.concat(imagesToSelect));
// }
},
imageSelectionToggled: (state, action: PayloadAction<string>) => {
if (
state.selection.includes(action.payload) &&
state.selection.length > 1
) {
state.selection = state.selection.filter(
(imageName) => imageName !== action.payload
);
} else {
state.selection = uniq(state.selection.concat(action.payload));
}
// if (
// state.selection.includes(action.payload) &&
// state.selection.length > 1
// ) {
// state.selection = state.selection.filter(
// (imageName) => imageName !== action.payload
// );
// } else {
// state.selection = uniq(state.selection.concat(action.payload));
// }
},
imageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload ? [action.payload] : [];
@ -143,15 +92,9 @@ export const gallerySlice = createSlice({
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload;
},
setGalleryView: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
boardIdSelected: (state, action: PayloadAction<BoardId>) => {
state.selectedBoardId = action.payload;
},
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isBatchEnabled = action.payload;
},
@ -182,47 +125,11 @@ export const gallerySlice = createSlice({
},
},
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(
boardsApi.endpoints.deleteBoard.matchFulfilled,
(state, action) => {
if (action.meta.arg.originalArgs === state.selectedBoardId) {
state.selectedBoardId = 'all';
state.selectedBoardId = 'images';
}
}
);
@ -230,26 +137,13 @@ export const gallerySlice = createSlice({
});
export const {
selectAll: selectImagesAll,
selectById: selectImagesById,
selectEntities: selectImagesEntities,
selectIds: selectImagesIds,
selectTotal: selectImagesTotal,
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery);
export const {
imageUpserted,
imageUpdatedOne,
imageRemoved,
imagesRemoved,
imageRangeEndSelected,
imageSelectionToggled,
imageSelected,
shouldAutoSwitchChanged,
setGalleryImageMinimumWidth,
setGalleryView,
boardIdSelected,
isLoadingChanged,
isBatchEnabledChanged,
imagesAddedToBatch,
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 { ImageUsage } from '../store/imageDeletionSlice';
import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
const { imageUsage } = props;
type Props = {
imageUsage?: ImageUsage;
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) {
return null;
@ -16,16 +24,14 @@ const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
return (
<>
<Text>This image is currently in use in the following features:</Text>
<Text>{topMessage}</Text>
<UnorderedList sx={{ paddingInlineStart: 6 }}>
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
</UnorderedList>
<Text>
If you delete this image, those features will immediately be reset.
</Text>
<Text>{bottomMessage}</Text>
</>
);
};

View File

@ -51,10 +51,42 @@ export type ImageUsage = {
isControlNetImage: boolean;
};
export const getImageUsage = (state: RootState, image_name: string) => {
const { generation, canvas, nodes, controlNet } = state;
const isInitialImage = generation.initialImage?.imageName === image_name;
const isCanvasImage = canvas.layerState.objects.some(
(obj) => obj.kind === 'image' && obj.imageName === image_name
);
const isNodesImage = nodes.nodes.some((node) => {
return some(
node.data.inputs,
(input) =>
input.type === 'image' && input.value?.image_name === image_name
);
});
const isControlNetImage = some(
controlNet.controlNets,
(c) =>
c.controlImage === image_name || c.processedControlImage === image_name
);
const imageUsage: ImageUsage = {
isInitialImage,
isCanvasImage,
isNodesImage,
isControlNetImage,
};
return imageUsage;
};
export const selectImageUsage = createSelector(
[(state: RootState) => state],
({ imageDeletion, generation, canvas, nodes, controlNet }) => {
const { imageToDelete } = imageDeletion;
(state) => {
const { imageToDelete } = state.imageDeletion;
if (!imageToDelete) {
return;
@ -62,32 +94,7 @@ export const selectImageUsage = createSelector(
const { image_name } = imageToDelete;
const isInitialImage = generation.initialImage?.imageName === image_name;
const isCanvasImage = canvas.layerState.objects.some(
(obj) => obj.kind === 'image' && obj.imageName === image_name
);
const isNodesImage = nodes.nodes.some((node) => {
return some(
node.data.inputs,
(input) =>
input.type === 'image' && input.value?.image_name === image_name
);
});
const isControlNetImage = some(
controlNet.controlNets,
(c) =>
c.controlImage === image_name || c.processedControlImage === image_name
);
const imageUsage: ImageUsage = {
isInitialImage,
isCanvasImage,
isNodesImage,
isControlNetImage,
};
const imageUsage = getImageUsage(state, image_name);
return imageUsage;
},

View File

@ -1,33 +1,46 @@
import { Flex, Image } from '@chakra-ui/react';
import { NodeProps } from 'reactflow';
import { InvocationValue } from '../types/types';
import { useAppSelector } from 'app/store/storeHooks';
import { RootState } from 'app/store/store';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { memo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { NodeProps, OnResize } from 'reactflow';
import { setProgressNodeSize } from '../store/nodesSlice';
import IAINodeHeader from './IAINode/IAINodeHeader';
import IAINodeResizer from './IAINode/IAINodeResizer';
import NodeWrapper from './NodeWrapper';
const ProgressImageNode = (props: NodeProps<InvocationValue>) => {
const progressImage = useAppSelector((state) => state.system.progressImage);
const ProgressImageNode = (props: NodeProps) => {
const progressImage = useSelector(
(state: RootState) => state.system.progressImage
);
const progressNodeSize = useSelector(
(state: RootState) => state.nodes.progressNodeSize
);
const dispatch = useDispatch();
const { selected } = props;
const handleResize: OnResize = (_, newSize) => {
dispatch(setProgressNodeSize(newSize));
};
return (
<NodeWrapper selected={selected}>
<IAINodeHeader
title="Progress Image"
description="Displays the progress image in the Node Editor"
/>
<Flex
className="nopan"
sx={{
flexDirection: 'column',
flexShrink: 0,
borderBottomRadius: 'md',
p: 2,
bg: 'base.200',
_dark: { bg: 'base.800' },
width: progressNodeSize.width - 2,
height: progressNodeSize.height - 2,
minW: 250,
minH: 250,
overflow: 'hidden',
}}
>
{progressImage ? (
@ -42,22 +55,17 @@ const ProgressImageNode = (props: NodeProps<InvocationValue>) => {
) : (
<Flex
sx={{
w: 'full',
h: 'full',
minW: 32,
minH: 32,
alignItems: 'center',
justifyContent: 'center',
minW: 250,
minH: 250,
width: progressNodeSize.width - 2,
height: progressNodeSize.height - 2,
}}
>
<IAINoContentFallback />
</Flex>
)}
</Flex>
<IAINodeResizer
maxHeight={progressImage?.height ?? 512}
maxWidth={progressImage?.width ?? 512}
/>
<IAINodeResizer onResize={handleResize} />
</NodeWrapper>
);
};

View File

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

View File

@ -35,6 +35,7 @@ export type NodesState = {
shouldShowFieldTypeLegend: boolean;
shouldShowMinimapPanel: boolean;
editorInstance: ReactFlowInstance | undefined;
progressNodeSize: { width: number; height: number };
};
export const initialNodesState: NodesState = {
@ -47,6 +48,7 @@ export const initialNodesState: NodesState = {
shouldShowFieldTypeLegend: false,
shouldShowMinimapPanel: true,
editorInstance: undefined,
progressNodeSize: { width: 512, height: 512 },
};
const nodesSlice = createSlice({
@ -157,6 +159,12 @@ const nodesSlice = createSlice({
loadFileEdges: (state, action: PayloadAction<Edge[]>) => {
state.edges = action.payload;
},
setProgressNodeSize: (
state,
action: PayloadAction<{ width: number; height: number }>
) => {
state.progressNodeSize = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => {
@ -182,6 +190,7 @@ export const {
setEditorInstance,
loadFileNodes,
loadFileEdges,
setProgressNodeSize,
} = nodesSlice.actions;
export default nodesSlice.reducer;

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ export const addVAEToGraph = (
graph.nodes[VAE_LOADER] = {
type: 'vae_loader',
id: VAE_LOADER,
is_intermediate: true,
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 { forEach } from 'lodash-es';
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
import { RootState } from 'app/store/store';
import { NonNullableGraph } from 'features/nodes/types/types';
import { ImageDTO } from 'services/api/types';
import { buildCanvasImageToImageGraph } from './buildCanvasImageToImageGraph';
import { buildCanvasInpaintGraph } from './buildCanvasInpaintGraph';
import { buildCanvasTextToImageGraph } from './buildCanvasTextToImageGraph';
const moduleLog = log.child({ namespace: 'nodes' });
@ -31,9 +30,5 @@ export const buildCanvasGraph = (
graph = buildCanvasInpaintGraph(state, canvasInitImage, canvasMaskImage);
}
forEach(graph.nodes, (node) => {
graph.nodes[node.id].is_intermediate = true;
});
return graph;
};

View File

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

View File

@ -61,12 +61,17 @@ export const buildCanvasInpaintGraph = (
const { width, height } = state.canvas.boundingBoxDimensions;
// 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 = {
id: INPAINT_GRAPH,
nodes: {
[INPAINT]: {
is_intermediate: !shouldAutoSave,
type: 'inpaint',
id: INPAINT,
steps,
@ -100,26 +105,31 @@ export const buildCanvasInpaintGraph = (
[POSITIVE_CONDITIONING]: {
type: 'compel',
id: POSITIVE_CONDITIONING,
is_intermediate: true,
prompt: positivePrompt,
},
[NEGATIVE_CONDITIONING]: {
type: 'compel',
id: NEGATIVE_CONDITIONING,
is_intermediate: true,
prompt: negativePrompt,
},
[MAIN_MODEL_LOADER]: {
type: 'main_model_loader',
id: MAIN_MODEL_LOADER,
is_intermediate: true,
model,
},
[CLIP_SKIP]: {
type: 'clip_skip',
id: CLIP_SKIP,
is_intermediate: true,
skipped_layers: clipSkip,
},
[RANGE_OF_SIZE]: {
type: 'range_of_size',
id: RANGE_OF_SIZE,
is_intermediate: true,
// seed - must be connected manually
// start: 0,
size: iterations,
@ -128,6 +138,7 @@ export const buildCanvasInpaintGraph = (
[ITERATE]: {
type: 'iterate',
id: ITERATE,
is_intermediate: true,
},
},
edges: [

View File

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

View File

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

View File

@ -244,22 +244,7 @@ export const useRecallParameters = () => {
[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
*/
const sendToImageToImage = useCallback(
@ -330,7 +315,6 @@ export const useRecallParameters = () => {
recallPositivePrompt,
recallNegativePrompt,
recallSeed,
recallInitialImage,
recallCfgScale,
recallModel,
recallScheduler,

View File

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

View File

@ -0,0 +1,60 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useCallback, useEffect, useState } from 'react';
import { StyledFlex } from './SettingsModal';
import { Heading, Text } from '@chakra-ui/react';
import IAIButton from '../../../../common/components/IAIButton';
import { useClearIntermediatesMutation } from '../../../../services/api/endpoints/images';
import { addToast } from '../../store/systemSlice';
import { resetCanvas } from '../../../canvas/store/canvasSlice';
export default function SettingsClearIntermediates() {
const dispatch = useAppDispatch();
const [isDisabled, setIsDisabled] = useState(false);
const [clearIntermediates, { isLoading: isLoadingClearIntermediates }] =
useClearIntermediatesMutation();
const handleClickClearIntermediates = useCallback(() => {
clearIntermediates({})
.unwrap()
.then((response) => {
dispatch(resetCanvas());
dispatch(
addToast({
title:
response === 0
? `No intermediates to clear`
: `Successfully cleared ${response} intermediates`,
status: 'info',
})
);
if (response < 100) {
setIsDisabled(true);
}
});
}, [clearIntermediates, dispatch]);
return (
<StyledFlex>
<Heading size="sm">Clear Intermediates</Heading>
<IAIButton
colorScheme="error"
onClick={handleClickClearIntermediates}
isLoading={isLoadingClearIntermediates}
isDisabled={isDisabled}
>
{isDisabled ? 'Intermediates Cleared' : 'Clear 100 Intermediates'}
</IAIButton>
<Text>
Will permanently delete first 100 intermediates found on disk and in
database
</Text>
<Text fontWeight="bold">This will also clear your canvas state.</Text>
<Text>
Intermediate images are byproducts of generation, different from the
result images in the gallery. Purging intermediates will free disk
space. Your gallery images will not be deleted.
</Text>
</StyledFlex>
);
}

View File

@ -11,7 +11,7 @@ import {
Text,
useDisclosure,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { createSelector, current } from '@reduxjs/toolkit';
import { VALID_LOG_LEVELS } from 'app/logging/useLogger';
import { LOCALSTORAGE_KEYS, LOCALSTORAGE_PREFIX } from 'app/store/constants';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@ -23,6 +23,7 @@ import {
SystemState,
consoleLogLevelChanged,
setEnableImageDebugging,
setIsNodesEnabled,
setShouldConfirmOnDelete,
setShouldDisplayGuides,
shouldAntialiasProgressImageChanged,
@ -48,6 +49,7 @@ import {
import { useTranslation } from 'react-i18next';
import { LogLevelName } from 'roarr';
import SettingsSchedulers from './SettingsSchedulers';
import SettingsClearIntermediates from './SettingsClearIntermediates';
const selector = createSelector(
[systemSelector, uiSelector],
@ -59,6 +61,7 @@ const selector = createSelector(
consoleLogLevel,
shouldLogToConsole,
shouldAntialiasProgressImage,
isNodesEnabled,
} = system;
const {
@ -79,6 +82,7 @@ const selector = createSelector(
shouldLogToConsole,
shouldAntialiasProgressImage,
shouldShowAdvancedOptions,
isNodesEnabled,
};
},
{
@ -91,6 +95,8 @@ type ConfigOptions = {
shouldShowResetWebUiText: boolean;
shouldShowBetaLayout: boolean;
shouldShowAdvancedOptionsSettings: boolean;
shouldShowClearIntermediates: boolean;
shouldShowNodesToggle: boolean;
};
type SettingsModalProps = {
@ -109,6 +115,9 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true;
const shouldShowAdvancedOptionsSettings =
config?.shouldShowAdvancedOptionsSettings ?? true;
const shouldShowClearIntermediates =
config?.shouldShowClearIntermediates ?? true;
const shouldShowNodesToggle = config?.shouldShowNodesToggle ?? true;
useEffect(() => {
if (!shouldShowDeveloperSettings) {
@ -139,6 +148,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
shouldLogToConsole,
shouldAntialiasProgressImage,
shouldShowAdvancedOptions,
isNodesEnabled,
} = useAppSelector(selector);
const handleClickResetWebUI = useCallback(() => {
@ -169,6 +179,13 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
[dispatch]
);
const handleToggleNodes = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(setIsNodesEnabled(e.target.checked));
},
[dispatch]
);
return (
<>
{cloneElement(children, {
@ -253,6 +270,13 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
)
}
/>
{shouldShowNodesToggle && (
<IAISwitch
label="Enable Nodes Editor (Experimental)"
isChecked={isNodesEnabled}
onChange={handleToggleNodes}
/>
)}
</StyledFlex>
{shouldShowDeveloperSettings && (
@ -280,6 +304,8 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
</StyledFlex>
)}
{shouldShowClearIntermediates && <SettingsClearIntermediates />}
<StyledFlex>
<Heading size="sm">{t('settings.resetWebUI')}</Heading>
<IAIButton colorScheme="error" onClick={handleClickResetWebUI}>
@ -328,7 +354,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
export default SettingsModal;
const StyledFlex = (props: PropsWithChildren) => {
export const StyledFlex = (props: PropsWithChildren) => {
return (
<Flex
sx={{

View File

@ -6,7 +6,6 @@ import { userInvoked } from 'app/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice';
import { t } from 'i18next';
import { LogLevelName } from 'roarr';
import { imageUploaded } from 'services/api/thunks/image';
import {
isAnySessionRejected,
sessionCanceled,
@ -86,6 +85,7 @@ export interface SystemState {
language: keyof typeof LANGUAGES;
isUploading: boolean;
boardIdToAddTo?: string;
isNodesEnabled: boolean;
}
export const initialSystemState: SystemState = {
@ -118,6 +118,7 @@ export const initialSystemState: SystemState = {
isPersisted: false,
language: 'en',
isUploading: false,
isNodesEnabled: false,
};
export const systemSlice = createSlice({
@ -193,6 +194,9 @@ export const systemSlice = createSlice({
progressImageSet(state, action: PayloadAction<ProgressImage | null>) {
state.progressImage = action.payload;
},
setIsNodesEnabled(state, action: PayloadAction<boolean>) {
state.isNodesEnabled = action.payload;
},
},
extraReducers(builder) {
/**
@ -360,27 +364,6 @@ export const systemSlice = createSlice({
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 ***
/**
@ -422,6 +405,7 @@ export const {
shouldAntialiasProgressImageChanged,
languageChanged,
progressImageSet,
setIsNodesEnabled,
} = systemSlice.actions;
export default systemSlice.reducer;

View File

@ -37,6 +37,7 @@ import NodesTab from './tabs/Nodes/NodesTab';
import ResizeHandle from './tabs/ResizeHandle';
import TextToImageTab from './tabs/TextToImage/TextToImageTab';
import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab';
import { systemSelector } from '../../system/store/systemSelectors';
export interface InvokeTabInfo {
id: InvokeTabName;
@ -84,11 +85,20 @@ const tabs: InvokeTabInfo[] = [
];
const enabledTabsSelector = createSelector(
configSelector,
(config) => {
[configSelector, systemSelector],
(config, system) => {
const { disabledTabs } = config;
const { isNodesEnabled } = system;
return tabs.filter((tab) => !disabledTabs.includes(tab.id));
const enabledTabs = tabs.filter((tab) => {
if (tab.id === 'nodes') {
return isNodesEnabled && !disabledTabs.includes(tab.id);
} else {
return !disabledTabs.includes(tab.id);
}
});
return enabledTabs;
},
{
memoizeOptions: { resultEqualityCheck: isEqual },

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More