merge with main

This commit is contained in:
Lincoln Stein
2023-05-13 21:35:19 -04:00
326 changed files with 6753 additions and 3928 deletions

View File

@ -1,13 +0,0 @@
{
"plugins": [
[
"transform-imports",
{
"lodash": {
"transform": "lodash/${member}",
"preventFullImport": true
}
}
]
]
}

View File

@ -34,4 +34,8 @@ stats.html
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
!.yarn/versions
# Yalc
.yalc
yalc.lock

View File

@ -5,6 +5,7 @@ import { PluginOption, UserConfig } from 'vite';
import dts from 'vite-plugin-dts';
import eslint from 'vite-plugin-eslint';
import tsconfigPaths from 'vite-tsconfig-paths';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
export const packageConfig: UserConfig = {
base: './',
@ -16,9 +17,10 @@ export const packageConfig: UserConfig = {
dts({
insertTypesEntry: true,
}),
cssInjectedByJsPlugin(),
],
build: {
chunkSizeWarningLimit: 1500,
cssCodeSplit: true,
lib: {
entry: path.resolve(__dirname, '../src/index.ts'),
name: 'InvokeAIUI',
@ -30,6 +32,7 @@ export const packageConfig: UserConfig = {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'@emotion/react': 'EmotionReact',
},
},
},

View File

@ -37,7 +37,7 @@ From `invokeai/frontend/web/` run `yarn install` to get everything set up.
Start everything in dev mode:
1. Start the dev server: `yarn dev`
2. Start the InvokeAI UI per usual: `invokeai --web`
2. Start the InvokeAI Nodes backend: `python scripts/invokeai-new.py --web # run from the repo root`
3. Point your browser to the dev server address e.g. <http://localhost:5173/>
### Production builds

View File

@ -21,7 +21,6 @@
"scripts": {
"prepare": "cd ../../../ && husky install invokeai/frontend/web/.husky",
"dev": "concurrently \"vite dev\" \"yarn run theme:watch\"",
"dev:nodes": "concurrently \"vite dev --mode nodes\" \"yarn run theme:watch\"",
"dev:host": "concurrently \"vite dev --host\" \"yarn run theme:watch\"",
"build": "yarn run lint && vite build",
"api:web": "openapi -i http://localhost:9090/openapi.json -o src/services/api --client axios --useOptions --useUnionTypes --exportSchemas true --indent 2 --request src/services/fixtures/request.ts",
@ -90,6 +89,7 @@
"react-konva": "^18.2.7",
"react-konva-utils": "^1.0.4",
"react-redux": "^8.0.5",
"react-resizable-panels": "^0.0.42",
"react-rnd": "^10.4.1",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
@ -99,6 +99,7 @@
"redux-deep-persist": "^1.0.7",
"redux-dynamic-middlewares": "^2.2.0",
"redux-persist": "^6.0.0",
"redux-remember": "^3.3.1",
"roarr": "^7.15.0",
"serialize-error": "^11.0.0",
"socket.io-client": "^4.6.0",
@ -118,6 +119,7 @@
"@types/node": "^18.16.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.1",
"@types/react-redux": "^7.1.25",
"@types/react-transition-group": "^4.4.5",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.1",
@ -143,6 +145,7 @@
"terser": "^5.17.1",
"ts-toolbelt": "^9.6.0",
"vite": "^4.3.3",
"vite-plugin-css-injected-by-js": "^3.1.1",
"vite-plugin-dts": "^2.3.0",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.2.0",

View File

@ -25,7 +25,7 @@
"common": {
"hotkeysLabel": "Hotkeys",
"themeLabel": "Theme",
"languagePickerLabel": "Language Picker",
"languagePickerLabel": "Language",
"reportBugLabel": "Report Bug",
"githubLabel": "Github",
"discordLabel": "Discord",
@ -54,7 +54,7 @@
"img2img": "Image To Image",
"unifiedCanvas": "Unified Canvas",
"linear": "Linear",
"nodes": "Nodes",
"nodes": "Node Editor",
"postprocessing": "Post Processing",
"nodesDesc": "A node based system for the generation of images is under development currently. Stay tuned for updates about this amazing feature.",
"postProcessing": "Post Processing",
@ -102,7 +102,8 @@
"generate": "Generate",
"openInNewTab": "Open in New Tab",
"dontAskMeAgain": "Don't ask me again",
"areYouSure": "Are you sure?"
"areYouSure": "Are you sure?",
"imagePrompt": "Image Prompt"
},
"gallery": {
"generations": "Generations",
@ -453,9 +454,10 @@
"seed": "Seed",
"imageToImage": "Image to Image",
"randomizeSeed": "Randomize Seed",
"shuffle": "Shuffle",
"shuffle": "Shuffle Seed",
"noiseThreshold": "Noise Threshold",
"perlinNoise": "Perlin Noise",
"noiseSettings": "Noise",
"variations": "Variations",
"variationAmount": "Variation Amount",
"seedWeights": "Seed Weights",
@ -470,6 +472,8 @@
"scale": "Scale",
"otherOptions": "Other Options",
"seamlessTiling": "Seamless Tiling",
"seamlessXAxis": "X Axis",
"seamlessYAxis": "Y Axis",
"hiresOptim": "High Res Optimization",
"hiresStrength": "High Res Strength",
"imageFit": "Fit Initial Image To Output Size",
@ -527,7 +531,8 @@
"useCanvasBeta": "Use Canvas Beta Layout",
"enableImageDebugging": "Enable Image Debugging",
"useSlidersForAll": "Use Sliders For All Options",
"autoShowProgress": "Auto Show Progress Images",
"showProgressInViewer": "Show Progress Images in Viewer",
"antialiasProgressImages": "Antialias Progress Images",
"resetWebUI": "Reset Web UI",
"resetWebUIDesc1": "Resetting the web UI only resets the browser's local cache of your images and remembered settings. It does not delete any images from disk.",
"resetWebUIDesc2": "If images aren't showing up in the gallery or something else isn't working, please try resetting before submitting an issue on GitHub.",
@ -549,8 +554,9 @@
"downloadImageStarted": "Image Download Started",
"imageCopied": "Image Copied",
"imageLinkCopied": "Image Link Copied",
"problemCopyingImageLink": "Unable to Copy Image Link",
"imageNotLoaded": "No Image Loaded",
"imageNotLoadedDesc": "No image found to send to image to image module",
"imageNotLoadedDesc": "Could not find image",
"imageSavedToGallery": "Image Saved to Gallery",
"canvasMerged": "Canvas Merged",
"sentToImageToImage": "Sent To Image To Image",
@ -645,7 +651,8 @@
"betaClear": "Clear",
"betaDarkenOutside": "Darken Outside",
"betaLimitToBox": "Limit To Box",
"betaPreserveMasked": "Preserve Masked"
"betaPreserveMasked": "Preserve Masked",
"antialiasing": "Antialiasing"
},
"ui": {
"showProgressImages": "Show Progress Images",

View File

@ -1,24 +1,18 @@
import ImageUploader from 'common/components/ImageUploader';
import ProgressBar from 'features/system/components/ProgressBar';
import SiteHeader from 'features/system/components/SiteHeader';
import ProgressBar from 'features/system/components/ProgressBar';
import InvokeTabs from 'features/ui/components/InvokeTabs';
import useToastWatcher from 'features/system/hooks/useToastWatcher';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons';
import { Box, Flex, Grid, Portal, useColorMode } from '@chakra-ui/react';
import { Box, Flex, Grid, Portal } from '@chakra-ui/react';
import { APP_HEIGHT, APP_WIDTH } from 'theme/util/constants';
import ImageGalleryPanel from 'features/gallery/components/ImageGalleryPanel';
import GalleryDrawer from 'features/gallery/components/ImageGalleryPanel';
import Lightbox from 'features/lightbox/components/Lightbox';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
memo,
PropsWithChildren,
useCallback,
useEffect,
useState,
} from 'react';
import { memo, ReactNode, useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Loading from 'common/components/Loading/Loading';
import { useIsApplicationReady } from 'features/system/hooks/useIsApplicationReady';
@ -27,20 +21,24 @@ import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { configChanged } from 'features/system/store/configSlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useLogger } from 'app/logging/useLogger';
import ProgressImagePreview from 'features/parameters/components/ProgressImagePreview';
import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import { languageSelector } from 'features/system/store/systemSelectors';
import i18n from 'i18n';
const DEFAULT_CONFIG = {};
interface Props extends PropsWithChildren {
interface Props {
config?: PartialAppConfig;
headerComponent?: ReactNode;
}
const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
useToastWatcher();
useGlobalHotkeys();
const log = useLogger();
const currentTheme = useAppSelector((state) => state.ui.currentTheme);
const language = useAppSelector(languageSelector);
const log = useLogger();
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
@ -48,18 +46,17 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
const [loadingOverridden, setLoadingOverridden] = useState(false);
const { setColorMode } = useColorMode();
const dispatch = useAppDispatch();
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
useEffect(() => {
log.info({ namespace: 'App', data: config }, 'Received config');
dispatch(configChanged(config));
}, [dispatch, config, log]);
useEffect(() => {
setColorMode(['light'].includes(currentTheme) ? 'light' : 'dark');
}, [setColorMode, currentTheme]);
const handleOverrideClicked = useCallback(() => {
setLoadingOverridden(true);
}, []);
@ -76,7 +73,7 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
w={APP_WIDTH}
h={APP_HEIGHT}
>
{children || <SiteHeader />}
{headerComponent || <SiteHeader />}
<Flex
gap={4}
w={{ base: '100vw', xl: 'full' }}
@ -84,11 +81,13 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
flexDir={{ base: 'column', xl: 'row' }}
>
<InvokeTabs />
<ImageGalleryPanel />
</Flex>
</Grid>
</ImageUploader>
<GalleryDrawer />
<ParametersDrawer />
<AnimatePresence>
{!isApplicationReady && !loadingOverridden && (
<motion.div
@ -121,7 +120,6 @@ const App = ({ config = DEFAULT_CONFIG, children }: Props) => {
<Portal>
<FloatingGalleryButton />
</Portal>
<ProgressImagePreview />
</Grid>
);
};

View File

@ -1,18 +1,13 @@
import React, { lazy, memo, PropsWithChildren, useEffect } from 'react';
import React, {
lazy,
memo,
PropsWithChildren,
ReactNode,
useEffect,
} from 'react';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { store } from 'app/store/store';
import { persistor } from '../store/persistor';
import { OpenAPI } from 'services/api';
import '@fontsource/inter/100.css';
import '@fontsource/inter/200.css';
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import '@fontsource/inter/900.css';
import Loading from '../../common/components/Loading/Loading';
import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares';
@ -28,9 +23,10 @@ interface Props extends PropsWithChildren {
apiUrl?: string;
token?: string;
config?: PartialAppConfig;
headerComponent?: ReactNode;
}
const InvokeAIUI = ({ apiUrl, token, config, children }: Props) => {
const InvokeAIUI = ({ apiUrl, token, config, headerComponent }: Props) => {
useEffect(() => {
// configure API client token
if (token) {
@ -57,13 +53,11 @@ const InvokeAIUI = ({ apiUrl, token, config, children }: Props) => {
return (
<React.StrictMode>
<Provider store={store}>
<PersistGate loading={<Loading />} persistor={persistor}>
<React.Suspense fallback={<Loading />}>
<ThemeLocaleProvider>
<App config={config}>{children}</App>
</ThemeLocaleProvider>
</React.Suspense>
</PersistGate>
<React.Suspense fallback={<Loading />}>
<ThemeLocaleProvider>
<App config={config} headerComponent={headerComponent} />
</ThemeLocaleProvider>
</React.Suspense>
</Provider>
</React.StrictMode>
);

View File

@ -1,4 +1,8 @@
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import {
ChakraProvider,
createLocalStorageManager,
extendTheme,
} from '@chakra-ui/react';
import { ReactNode, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { theme as invokeAITheme } from 'theme/theme';
@ -9,15 +13,8 @@ import { greenTeaThemeColors } from 'theme/colors/greenTea';
import { invokeAIThemeColors } from 'theme/colors/invokeAI';
import { lightThemeColors } from 'theme/colors/lightTheme';
import { oceanBlueColors } from 'theme/colors/oceanBlue';
import '@fontsource/inter/100.css';
import '@fontsource/inter/200.css';
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import '@fontsource/inter/900.css';
import '@fontsource/inter/variable.css';
import 'overlayscrollbars/overlayscrollbars.css';
import 'theme/css/overlayscrollbars.css';
@ -32,6 +29,8 @@ const THEMES = {
ocean: oceanBlueColors,
};
const manager = createLocalStorageManager('@@invokeai-color-mode');
function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) {
const { i18n } = useTranslation();
@ -51,7 +50,11 @@ function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) {
document.body.dir = direction;
}, [direction]);
return <ChakraProvider theme={theme}>{children}</ChakraProvider>;
return (
<ChakraProvider theme={theme} colorModeManager={manager}>
{children}
</ChakraProvider>
);
}
export default ThemeLocaleProvider;

View File

@ -2,17 +2,28 @@
export const DIFFUSERS_SCHEDULERS: Array<string> = [
'ddim',
'plms',
'k_lms',
'dpmpp_2',
'k_dpm_2',
'k_dpm_2_a',
'k_dpmpp_2',
'k_euler',
'k_euler_a',
'k_heun',
'ddpm',
'deis',
'lms',
'pndm',
'heun',
'euler',
'euler_k',
'euler_a',
'kdpm_2',
'kdpm_2_a',
'dpmpp_2s',
'dpmpp_2m',
'dpmpp_2m_k',
'unipc',
];
export const IMG2IMG_DIFFUSERS_SCHEDULERS = DIFFUSERS_SCHEDULERS.filter(
(scheduler) => {
return scheduler !== 'dpmpp_2s';
}
);
// Valid image widths
export const WIDTHS: Array<number> = Array.from(Array(64)).map(
(_x, i) => (i + 1) * 64

View File

@ -1,26 +1,20 @@
import { createSelector } from '@reduxjs/toolkit';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { validateSeedWeights } from 'common/util/seedWeightPairs';
import { initialCanvasImageSelector } from 'features/canvas/store/canvasSelectors';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash-es';
export const readinessSelector = createSelector(
[
generationSelector,
systemSelector,
initialCanvasImageSelector,
activeTabNameSelector,
],
(generation, system, initialCanvasImage, activeTabName) => {
[generationSelector, systemSelector, activeTabNameSelector],
(generation, system, activeTabName) => {
const {
prompt,
shouldGenerateVariations,
seedWeights,
initialImage,
seed,
isImageToImageEnabled,
} = generation;
const { isProcessing, isConnected } = system;
@ -34,7 +28,7 @@ export const readinessSelector = createSelector(
reasonsWhyNotReady.push('Missing prompt');
}
if (isImageToImageEnabled && !initialImage) {
if (activeTabName === 'img2img' && !initialImage) {
isReady = false;
reasonsWhyNotReady.push('No initial image selected');
}
@ -64,10 +58,5 @@ export const readinessSelector = createSelector(
// All good
return { isReady, reasonsWhyNotReady };
},
{
memoizeOptions: {
equalityCheck: isEqual,
resultEqualityCheck: isEqual,
},
}
defaultSelectorOptions
);

View File

@ -1,209 +1,209 @@
// import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
// import * as InvokeAI from 'app/types/invokeai';
// import type { RootState } from 'app/store/store';
// import {
// frontendToBackendParameters,
// FrontendToBackendParametersConfig,
// } from 'common/util/parameterTranslation';
// import dateFormat from 'dateformat';
// import {
// GalleryCategory,
// GalleryState,
// removeImage,
// } from 'features/gallery/store/gallerySlice';
// import {
// generationRequested,
// modelChangeRequested,
// modelConvertRequested,
// modelMergingRequested,
// setIsProcessing,
// } from 'features/system/store/systemSlice';
// import { InvokeTabName } from 'features/ui/store/tabMap';
// import { Socket } from 'socket.io-client';
import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/types/invokeai';
import type { RootState } from 'app/store/store';
import {
frontendToBackendParameters,
FrontendToBackendParametersConfig,
} from 'common/util/parameterTranslation';
import dateFormat from 'dateformat';
import {
GalleryCategory,
GalleryState,
removeImage,
} from 'features/gallery/store/gallerySlice';
import {
generationRequested,
modelChangeRequested,
modelConvertRequested,
modelMergingRequested,
setIsProcessing,
} from 'features/system/store/systemSlice';
import { InvokeTabName } from 'features/ui/store/tabMap';
import { Socket } from 'socket.io-client';
// /**
// * Returns an object containing all functions which use `socketio.emit()`.
// * i.e. those which make server requests.
// */
// const makeSocketIOEmitters = (
// store: MiddlewareAPI<Dispatch<AnyAction>, RootState>,
// socketio: Socket
// ) => {
// // We need to dispatch actions to redux and get pieces of state from the store.
// const { dispatch, getState } = store;
/**
* Returns an object containing all functions which use `socketio.emit()`.
* i.e. those which make server requests.
*/
const makeSocketIOEmitters = (
store: MiddlewareAPI<Dispatch<AnyAction>, RootState>,
socketio: Socket
) => {
// We need to dispatch actions to redux and get pieces of state from the store.
const { dispatch, getState } = store;
// return {
// emitGenerateImage: (generationMode: InvokeTabName) => {
// dispatch(setIsProcessing(true));
return {
emitGenerateImage: (generationMode: InvokeTabName) => {
dispatch(setIsProcessing(true));
// const state: RootState = getState();
const state: RootState = getState();
// const {
// generation: generationState,
// postprocessing: postprocessingState,
// system: systemState,
// canvas: canvasState,
// } = state;
const {
generation: generationState,
postprocessing: postprocessingState,
system: systemState,
canvas: canvasState,
} = state;
// const frontendToBackendParametersConfig: FrontendToBackendParametersConfig =
// {
// generationMode,
// generationState,
// postprocessingState,
// canvasState,
// systemState,
// };
const frontendToBackendParametersConfig: FrontendToBackendParametersConfig =
{
generationMode,
generationState,
postprocessingState,
canvasState,
systemState,
};
// dispatch(generationRequested());
dispatch(generationRequested());
// const { generationParameters, esrganParameters, facetoolParameters } =
// frontendToBackendParameters(frontendToBackendParametersConfig);
const { generationParameters, esrganParameters, facetoolParameters } =
frontendToBackendParameters(frontendToBackendParametersConfig);
// socketio.emit(
// 'generateImage',
// generationParameters,
// esrganParameters,
// facetoolParameters
// );
socketio.emit(
'generateImage',
generationParameters,
esrganParameters,
facetoolParameters
);
// // we need to truncate the init_mask base64 else it takes up the whole log
// // TODO: handle maintaining masks for reproducibility in future
// if (generationParameters.init_mask) {
// generationParameters.init_mask = generationParameters.init_mask
// .substr(0, 64)
// .concat('...');
// }
// if (generationParameters.init_img) {
// generationParameters.init_img = generationParameters.init_img
// .substr(0, 64)
// .concat('...');
// }
// we need to truncate the init_mask base64 else it takes up the whole log
// TODO: handle maintaining masks for reproducibility in future
if (generationParameters.init_mask) {
generationParameters.init_mask = generationParameters.init_mask
.substr(0, 64)
.concat('...');
}
if (generationParameters.init_img) {
generationParameters.init_img = generationParameters.init_img
.substr(0, 64)
.concat('...');
}
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Image generation requested: ${JSON.stringify({
// ...generationParameters,
// ...esrganParameters,
// ...facetoolParameters,
// })}`,
// })
// );
// },
// emitRunESRGAN: (imageToProcess: InvokeAI._Image) => {
// dispatch(setIsProcessing(true));
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Image generation requested: ${JSON.stringify({
...generationParameters,
...esrganParameters,
...facetoolParameters,
})}`,
})
);
},
emitRunESRGAN: (imageToProcess: InvokeAI._Image) => {
dispatch(setIsProcessing(true));
// const {
// postprocessing: {
// upscalingLevel,
// upscalingDenoising,
// upscalingStrength,
// },
// } = getState();
const {
postprocessing: {
upscalingLevel,
upscalingDenoising,
upscalingStrength,
},
} = getState();
// const esrganParameters = {
// upscale: [upscalingLevel, upscalingDenoising, upscalingStrength],
// };
// socketio.emit('runPostprocessing', imageToProcess, {
// type: 'esrgan',
// ...esrganParameters,
// });
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `ESRGAN upscale requested: ${JSON.stringify({
// file: imageToProcess.url,
// ...esrganParameters,
// })}`,
// })
// );
// },
// emitRunFacetool: (imageToProcess: InvokeAI._Image) => {
// dispatch(setIsProcessing(true));
const esrganParameters = {
upscale: [upscalingLevel, upscalingDenoising, upscalingStrength],
};
socketio.emit('runPostprocessing', imageToProcess, {
type: 'esrgan',
...esrganParameters,
});
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `ESRGAN upscale requested: ${JSON.stringify({
file: imageToProcess.url,
...esrganParameters,
})}`,
})
);
},
emitRunFacetool: (imageToProcess: InvokeAI._Image) => {
dispatch(setIsProcessing(true));
// const {
// postprocessing: { facetoolType, facetoolStrength, codeformerFidelity },
// } = getState();
const {
postprocessing: { facetoolType, facetoolStrength, codeformerFidelity },
} = getState();
// const facetoolParameters: Record<string, unknown> = {
// facetool_strength: facetoolStrength,
// };
const facetoolParameters: Record<string, unknown> = {
facetool_strength: facetoolStrength,
};
// if (facetoolType === 'codeformer') {
// facetoolParameters.codeformer_fidelity = codeformerFidelity;
// }
if (facetoolType === 'codeformer') {
facetoolParameters.codeformer_fidelity = codeformerFidelity;
}
// socketio.emit('runPostprocessing', imageToProcess, {
// type: facetoolType,
// ...facetoolParameters,
// });
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Face restoration (${facetoolType}) requested: ${JSON.stringify(
// {
// file: imageToProcess.url,
// ...facetoolParameters,
// }
// )}`,
// })
// );
// },
// emitDeleteImage: (imageToDelete: InvokeAI._Image) => {
// const { url, uuid, category, thumbnail } = imageToDelete;
// dispatch(removeImage(imageToDelete));
// socketio.emit('deleteImage', url, thumbnail, uuid, category);
// },
// emitRequestImages: (category: GalleryCategory) => {
// const gallery: GalleryState = getState().gallery;
// const { earliest_mtime } = gallery.categories[category];
// socketio.emit('requestImages', category, earliest_mtime);
// },
// emitRequestNewImages: (category: GalleryCategory) => {
// const gallery: GalleryState = getState().gallery;
// const { latest_mtime } = gallery.categories[category];
// socketio.emit('requestLatestImages', category, latest_mtime);
// },
// emitCancelProcessing: () => {
// socketio.emit('cancel');
// },
// emitRequestSystemConfig: () => {
// socketio.emit('requestSystemConfig');
// },
// emitSearchForModels: (modelFolder: string) => {
// socketio.emit('searchForModels', modelFolder);
// },
// emitAddNewModel: (modelConfig: InvokeAI.InvokeModelConfigProps) => {
// socketio.emit('addNewModel', modelConfig);
// },
// emitDeleteModel: (modelName: string) => {
// socketio.emit('deleteModel', modelName);
// },
// emitConvertToDiffusers: (
// modelToConvert: InvokeAI.InvokeModelConversionProps
// ) => {
// dispatch(modelConvertRequested());
// socketio.emit('convertToDiffusers', modelToConvert);
// },
// emitMergeDiffusersModels: (
// modelMergeInfo: InvokeAI.InvokeModelMergingProps
// ) => {
// dispatch(modelMergingRequested());
// socketio.emit('mergeDiffusersModels', modelMergeInfo);
// },
// emitRequestModelChange: (modelName: string) => {
// dispatch(modelChangeRequested());
// socketio.emit('requestModelChange', modelName);
// },
// emitSaveStagingAreaImageToGallery: (url: string) => {
// socketio.emit('requestSaveStagingAreaImageToGallery', url);
// },
// emitRequestEmptyTempFolder: () => {
// socketio.emit('requestEmptyTempFolder');
// },
// };
// };
socketio.emit('runPostprocessing', imageToProcess, {
type: facetoolType,
...facetoolParameters,
});
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Face restoration (${facetoolType}) requested: ${JSON.stringify(
{
file: imageToProcess.url,
...facetoolParameters,
}
)}`,
})
);
},
emitDeleteImage: (imageToDelete: InvokeAI._Image) => {
const { url, uuid, category, thumbnail } = imageToDelete;
dispatch(removeImage(imageToDelete));
socketio.emit('deleteImage', url, thumbnail, uuid, category);
},
emitRequestImages: (category: GalleryCategory) => {
const gallery: GalleryState = getState().gallery;
const { earliest_mtime } = gallery.categories[category];
socketio.emit('requestImages', category, earliest_mtime);
},
emitRequestNewImages: (category: GalleryCategory) => {
const gallery: GalleryState = getState().gallery;
const { latest_mtime } = gallery.categories[category];
socketio.emit('requestLatestImages', category, latest_mtime);
},
emitCancelProcessing: () => {
socketio.emit('cancel');
},
emitRequestSystemConfig: () => {
socketio.emit('requestSystemConfig');
},
emitSearchForModels: (modelFolder: string) => {
socketio.emit('searchForModels', modelFolder);
},
emitAddNewModel: (modelConfig: InvokeAI.InvokeModelConfigProps) => {
socketio.emit('addNewModel', modelConfig);
},
emitDeleteModel: (modelName: string) => {
socketio.emit('deleteModel', modelName);
},
emitConvertToDiffusers: (
modelToConvert: InvokeAI.InvokeModelConversionProps
) => {
dispatch(modelConvertRequested());
socketio.emit('convertToDiffusers', modelToConvert);
},
emitMergeDiffusersModels: (
modelMergeInfo: InvokeAI.InvokeModelMergingProps
) => {
dispatch(modelMergingRequested());
socketio.emit('mergeDiffusersModels', modelMergeInfo);
},
emitRequestModelChange: (modelName: string) => {
dispatch(modelChangeRequested());
socketio.emit('requestModelChange', modelName);
},
emitSaveStagingAreaImageToGallery: (url: string) => {
socketio.emit('requestSaveStagingAreaImageToGallery', url);
},
emitRequestEmptyTempFolder: () => {
socketio.emit('requestEmptyTempFolder');
},
};
};
// export default makeSocketIOEmitters;
export default makeSocketIOEmitters;
export default {};

View File

@ -0,0 +1,4 @@
import { createAction } from '@reduxjs/toolkit';
import { InvokeTabName } from 'features/ui/store/tabMap';
export const userInvoked = createAction<InvokeTabName>('app/userInvoked');

View File

@ -0,0 +1,8 @@
export const LOCALSTORAGE_KEYS = [
'chakra-ui-color-mode',
'i18nextLng',
'ROARR_FILTER',
'ROARR_LOG',
];
export const LOCALSTORAGE_PREFIX = '@@invokeai-';

View File

@ -0,0 +1,36 @@
import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist';
import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist';
import { resultsPersistDenylist } from 'features/gallery/store/resultsPersistDenylist';
import { uploadsPersistDenylist } from 'features/gallery/store/uploadsPersistDenylist';
import { lightboxPersistDenylist } from 'features/lightbox/store/lightboxPersistDenylist';
import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist';
import { generationPersistDenylist } from 'features/parameters/store/generationPersistDenylist';
import { postprocessingPersistDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
import { modelsPersistDenylist } from 'features/system/store/modelsPersistDenylist';
import { systemPersistDenylist } from 'features/system/store/systemPersistDenylist';
import { uiPersistDenylist } from 'features/ui/store/uiPersistDenylist';
import { omit } from 'lodash-es';
import { SerializeFunction } from 'redux-remember';
const serializationDenylist: {
[key: string]: string[];
} = {
canvas: canvasPersistDenylist,
gallery: galleryPersistDenylist,
generation: generationPersistDenylist,
lightbox: lightboxPersistDenylist,
models: modelsPersistDenylist,
nodes: nodesPersistDenylist,
postprocessing: postprocessingPersistDenylist,
results: resultsPersistDenylist,
system: systemPersistDenylist,
// config: configPersistDenyList,
ui: uiPersistDenylist,
uploads: uploadsPersistDenylist,
// hotkeys: hotkeysPersistDenylist,
};
export const serialize: SerializeFunction = (data, key) => {
const result = omit(data, serializationDenylist[key]);
return JSON.stringify(result);
};

View File

@ -0,0 +1,38 @@
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
import { initialResultsState } from 'features/gallery/store/resultsSlice';
import { initialUploadsState } from 'features/gallery/store/uploadsSlice';
import { initialLightboxState } from 'features/lightbox/store/lightboxSlice';
import { initialNodesState } from 'features/nodes/store/nodesSlice';
import { initialGenerationState } from 'features/parameters/store/generationSlice';
import { initialPostprocessingState } from 'features/parameters/store/postprocessingSlice';
import { initialConfigState } from 'features/system/store/configSlice';
import { initialModelsState } from 'features/system/store/modelSlice';
import { initialSystemState } from 'features/system/store/systemSlice';
import { initialHotkeysState } from 'features/ui/store/hotkeysSlice';
import { initialUIState } from 'features/ui/store/uiSlice';
import { defaultsDeep } from 'lodash-es';
import { UnserializeFunction } from 'redux-remember';
const initialStates: {
[key: string]: any;
} = {
canvas: initialCanvasState,
gallery: initialGalleryState,
generation: initialGenerationState,
lightbox: initialLightboxState,
models: initialModelsState,
nodes: initialNodesState,
postprocessing: initialPostprocessingState,
results: initialResultsState,
system: initialSystemState,
config: initialConfigState,
ui: initialUIState,
uploads: initialUploadsState,
hotkeys: initialHotkeysState,
};
export const unserialize: UnserializeFunction = (data, key) => {
const result = defaultsDeep(JSON.parse(data), initialStates[key]);
return result;
};

View File

@ -0,0 +1,30 @@
import { AnyAction } from '@reduxjs/toolkit';
import { isAnyGraphBuilt } from 'features/nodes/store/actions';
import { forEach } from 'lodash-es';
import { Graph } from 'services/api';
export const actionSanitizer = <A extends AnyAction>(action: A): A => {
if (isAnyGraphBuilt(action)) {
if (action.payload.nodes) {
const sanitizedNodes: Graph['nodes'] = {};
// Sanitize nodes as needed
forEach(action.payload.nodes, (node, key) => {
// Don't log the whole freaking dataURL
if (node.type === 'dataURL_image') {
const { dataURL, ...rest } = node;
sanitizedNodes[key] = { ...rest, dataURL: '<dataURL>' };
} else {
sanitizedNodes[key] = { ...node };
}
});
return {
...action,
payload: { ...action.payload, nodes: sanitizedNodes },
};
}
}
return action;
};

View File

@ -0,0 +1,11 @@
export const actionsDenylist = [
'canvas/setCursorPosition',
'canvas/setStageCoordinates',
'canvas/setStageScale',
'canvas/setIsDrawing',
'canvas/setBoundingBoxCoordinates',
'canvas/setBoundingBoxDimensions',
'canvas/setIsDrawing',
'canvas/addPointToCurrentLine',
'socket/generatorProgress',
];

View File

@ -0,0 +1,3 @@
export const stateSanitizer = <S>(state: S): S => {
return state;
};

View File

@ -0,0 +1,45 @@
import {
createListenerMiddleware,
addListener,
ListenerEffect,
AnyAction,
} from '@reduxjs/toolkit';
import type { TypedStartListening, TypedAddListener } from '@reduxjs/toolkit';
import type { RootState, AppDispatch } from '../../store';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addImageResultReceivedListener } from './listeners/invocationComplete';
import { addImageUploadedListener } from './listeners/imageUploaded';
import { addRequestedImageDeletionListener } from './listeners/imageDeleted';
import { addUserInvokedCanvasListener } from './listeners/userInvokedCanvas';
import { addUserInvokedNodesListener } from './listeners/userInvokedNodes';
import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextToImage';
import { addUserInvokedImageToImageListener } from './listeners/userInvokedImageToImage';
export const listenerMiddleware = createListenerMiddleware();
export type AppStartListening = TypedStartListening<RootState, AppDispatch>;
export const startAppListening =
listenerMiddleware.startListening as AppStartListening;
export const addAppListener = addListener as TypedAddListener<
RootState,
AppDispatch
>;
export type AppListenerEffect = ListenerEffect<
AnyAction,
RootState,
AppDispatch
>;
addImageUploadedListener();
addInitialImageSelectedListener();
addImageResultReceivedListener();
addRequestedImageDeletionListener();
addUserInvokedCanvasListener();
addUserInvokedNodesListener();
addUserInvokedTextToImageListener();
addUserInvokedImageToImageListener();

View File

@ -0,0 +1,31 @@
import { canvasGraphBuilt } from 'features/nodes/store/actions';
import { startAppListening } from '..';
import {
canvasSessionIdChanged,
stagingAreaInitialized,
} from 'features/canvas/store/canvasSlice';
import { sessionInvoked } from 'services/thunks/session';
export const addCanvasGraphBuiltListener = () =>
startAppListening({
actionCreator: canvasGraphBuilt,
effect: async (action, { dispatch, getState, take }) => {
const [{ meta }] = await take(sessionInvoked.fulfilled.match);
const { sessionId } = meta.arg;
const state = getState();
if (!state.canvas.layerState.stagingArea.boundingBox) {
dispatch(
stagingAreaInitialized({
sessionId,
boundingBox: {
...state.canvas.boundingBoxCoordinates,
...state.canvas.boundingBoxDimensions,
},
})
);
}
dispatch(canvasSessionIdChanged(sessionId));
},
});

View File

@ -0,0 +1,59 @@
import { requestedImageDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import { imageDeleted } from 'services/thunks/image';
import { log } from 'app/logging/useLogger';
import { clamp } from 'lodash-es';
import { imageSelected } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'addRequestedImageDeletionListener' });
export const addRequestedImageDeletionListener = () => {
startAppListening({
actionCreator: requestedImageDeletion,
effect: (action, { dispatch, getState }) => {
const image = action.payload;
if (!image) {
moduleLog.warn('No image provided');
return;
}
const { name, type } = image;
if (type !== 'uploads' && type !== 'results') {
moduleLog.warn({ data: image }, `Invalid image type ${type}`);
return;
}
const selectedImageName = getState().gallery.selectedImage?.name;
if (selectedImageName === name) {
const allIds = getState()[type].ids;
const allEntities = getState()[type].entities;
const deletedImageIndex = allIds.findIndex(
(result) => result.toString() === name
);
const filteredIds = allIds.filter((id) => id.toString() !== name);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
const newSelectedImage = allEntities[newSelectedImageId];
if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImage));
} else {
dispatch(imageSelected());
}
}
dispatch(imageDeleted({ imageName: name, imageType: type }));
},
});
};

View File

@ -0,0 +1,25 @@
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import { startAppListening } from '..';
import { uploadAdded } from 'features/gallery/store/uploadsSlice';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageUploaded } from 'services/thunks/image';
export const addImageUploadedListener = () => {
startAppListening({
predicate: (action): action is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(action) &&
action.payload.response.image_type !== 'intermediates',
effect: (action, { dispatch, getState }) => {
const { response } = action.payload;
const state = getState();
const image = deserializeImageResponse(response);
dispatch(uploadAdded(image));
if (state.gallery.shouldAutoSwitchToNewImages) {
dispatch(imageSelected(image));
}
},
});
};

View File

@ -0,0 +1,54 @@
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { Image, isInvokeAIImage } from 'app/types/invokeai';
import { selectResultsById } from 'features/gallery/store/resultsSlice';
import { selectUploadsById } from 'features/gallery/store/uploadsSlice';
import { makeToast } from 'features/system/hooks/useToastWatcher';
import { t } from 'i18next';
import { addToast } from 'features/system/store/systemSlice';
import { startAppListening } from '..';
import { initialImageSelected } from 'features/parameters/store/actions';
export const addInitialImageSelectedListener = () => {
startAppListening({
actionCreator: initialImageSelected,
effect: (action, { getState, dispatch }) => {
if (!action.payload) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
if (isInvokeAIImage(action.payload)) {
dispatch(initialImageChanged(action.payload));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
return;
}
const { name, type } = action.payload;
let image: Image | undefined;
const state = getState();
if (type === 'results') {
image = selectResultsById(state, name);
} else if (type === 'uploads') {
image = selectUploadsById(state, name);
}
if (!image) {
dispatch(
addToast(
makeToast({ title: t('toast.imageNotLoadedDesc'), status: 'error' })
)
);
return;
}
dispatch(initialImageChanged(image));
dispatch(addToast(makeToast(t('toast.sentToImageToImage'))));
},
});
};

View File

@ -0,0 +1,88 @@
import { invocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/types/guards';
import {
buildImageUrls,
extractTimestampFromImageName,
} from 'services/util/deserializeImageField';
import { Image } from 'app/types/invokeai';
import { resultAdded } from 'features/gallery/store/resultsSlice';
import { imageReceived, thumbnailReceived } from 'services/thunks/image';
import { startAppListening } from '..';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
const nodeDenylist = ['dataURL_image'];
export const addImageResultReceivedListener = () => {
startAppListening({
predicate: (action) => {
if (
invocationComplete.match(action) &&
isImageOutput(action.payload.data.result)
) {
return true;
}
return false;
},
effect: (action, { getState, dispatch }) => {
if (!invocationComplete.match(action)) {
return;
}
const { data, shouldFetchImages } = action.payload;
const { result, node, graph_execution_state_id } = data;
if (isImageOutput(result) && !nodeDenylist.includes(node.type)) {
const name = result.image.image_name;
const type = result.image.image_type;
const state = getState();
// if we need to refetch, set URLs to placeholder for now
const { url, thumbnail } = shouldFetchImages
? { url: '', thumbnail: '' }
: buildImageUrls(type, name);
const timestamp = extractTimestampFromImageName(name);
const image: Image = {
name,
type,
url,
thumbnail,
metadata: {
created: timestamp,
width: result.width,
height: result.height,
invokeai: {
session_id: graph_execution_state_id,
...(node ? { node } : {}),
},
},
};
dispatch(resultAdded(image));
if (state.gallery.shouldAutoSwitchToNewImages) {
dispatch(imageSelected(image));
}
if (state.config.shouldFetchImages) {
dispatch(imageReceived({ imageName: name, imageType: type }));
dispatch(
thumbnailReceived({
thumbnailName: name,
thumbnailType: type,
})
);
}
if (
graph_execution_state_id ===
state.canvas.layerState.stagingArea.sessionId
) {
dispatch(addImageToStagingArea(image));
}
}
},
});
};

View File

@ -0,0 +1,126 @@
import { startAppListening } from '..';
import { sessionCreated, sessionInvoked } from 'services/thunks/session';
import { buildCanvasGraphAndBlobs } from 'features/nodes/util/graphBuilders/buildCanvasGraph';
import { log } from 'app/logging/useLogger';
import { canvasGraphBuilt } from 'features/nodes/store/actions';
import { imageUploaded } from 'services/thunks/image';
import { v4 as uuidv4 } from 'uuid';
import { Graph } from 'services/api';
import {
canvasSessionIdChanged,
stagingAreaInitialized,
} from 'features/canvas/store/canvasSlice';
import { userInvoked } from 'app/store/actions';
const moduleLog = log.child({ namespace: 'invoke' });
export const addUserInvokedCanvasListener = () => {
startAppListening({
predicate: (action): action is ReturnType<typeof userInvoked> =>
userInvoked.match(action) && action.payload === 'unifiedCanvas',
effect: async (action, { getState, dispatch, take }) => {
const state = getState();
const data = await buildCanvasGraphAndBlobs(state);
if (!data) {
moduleLog.error('Problem building graph');
return;
}
const {
rangeNode,
iterateNode,
baseNode,
edges,
baseBlob,
maskBlob,
generationMode,
} = data;
const baseFilename = `${uuidv4()}.png`;
const maskFilename = `${uuidv4()}.png`;
dispatch(
imageUploaded({
imageType: 'intermediates',
formData: {
file: new File([baseBlob], baseFilename, { type: 'image/png' }),
},
})
);
if (baseNode.type === 'img2img' || baseNode.type === 'inpaint') {
const [{ payload: basePayload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(action) &&
action.meta.arg.formData.file.name === baseFilename
);
const { image_name: baseName, image_type: baseType } =
basePayload.response;
baseNode.image = {
image_name: baseName,
image_type: baseType,
};
}
if (baseNode.type === 'inpaint') {
dispatch(
imageUploaded({
imageType: 'intermediates',
formData: {
file: new File([maskBlob], maskFilename, { type: 'image/png' }),
},
})
);
const [{ payload: maskPayload }] = await take(
(action): action is ReturnType<typeof imageUploaded.fulfilled> =>
imageUploaded.fulfilled.match(action) &&
action.meta.arg.formData.file.name === maskFilename
);
const { image_name: maskName, image_type: maskType } =
maskPayload.response;
baseNode.mask = {
image_name: maskName,
image_type: maskType,
};
}
// Assemble!
const nodes: Graph['nodes'] = {
[rangeNode.id]: rangeNode,
[iterateNode.id]: iterateNode,
[baseNode.id]: baseNode,
};
const graph = { nodes, edges };
dispatch(canvasGraphBuilt(graph));
moduleLog({ data: graph }, 'Canvas graph built');
dispatch(sessionCreated({ graph }));
const [{ meta }] = await take(sessionInvoked.fulfilled.match);
const { sessionId } = meta.arg;
if (!state.canvas.layerState.stagingArea.boundingBox) {
dispatch(
stagingAreaInitialized({
sessionId,
boundingBox: {
...state.canvas.boundingBoxCoordinates,
...state.canvas.boundingBoxDimensions,
},
})
);
}
dispatch(canvasSessionIdChanged(sessionId));
},
});
};

View File

@ -0,0 +1,24 @@
import { startAppListening } from '..';
import { buildImageToImageGraph } from 'features/nodes/util/graphBuilders/buildImageToImageGraph';
import { sessionCreated } from 'services/thunks/session';
import { log } from 'app/logging/useLogger';
import { imageToImageGraphBuilt } from 'features/nodes/store/actions';
import { userInvoked } from 'app/store/actions';
const moduleLog = log.child({ namespace: 'invoke' });
export const addUserInvokedImageToImageListener = () => {
startAppListening({
predicate: (action): action is ReturnType<typeof userInvoked> =>
userInvoked.match(action) && action.payload === 'img2img',
effect: (action, { getState, dispatch }) => {
const state = getState();
const graph = buildImageToImageGraph(state);
dispatch(imageToImageGraphBuilt(graph));
moduleLog({ data: graph }, 'Image to Image graph built');
dispatch(sessionCreated({ graph }));
},
});
};

View File

@ -0,0 +1,24 @@
import { startAppListening } from '..';
import { sessionCreated } from 'services/thunks/session';
import { buildNodesGraph } from 'features/nodes/util/graphBuilders/buildNodesGraph';
import { log } from 'app/logging/useLogger';
import { nodesGraphBuilt } from 'features/nodes/store/actions';
import { userInvoked } from 'app/store/actions';
const moduleLog = log.child({ namespace: 'invoke' });
export const addUserInvokedNodesListener = () => {
startAppListening({
predicate: (action): action is ReturnType<typeof userInvoked> =>
userInvoked.match(action) && action.payload === 'nodes',
effect: (action, { getState, dispatch }) => {
const state = getState();
const graph = buildNodesGraph(state);
dispatch(nodesGraphBuilt(graph));
moduleLog({ data: graph }, 'Nodes graph built');
dispatch(sessionCreated({ graph }));
},
});
};

View File

@ -0,0 +1,24 @@
import { startAppListening } from '..';
import { buildTextToImageGraph } from 'features/nodes/util/graphBuilders/buildTextToImageGraph';
import { sessionCreated } from 'services/thunks/session';
import { log } from 'app/logging/useLogger';
import { textToImageGraphBuilt } from 'features/nodes/store/actions';
import { userInvoked } from 'app/store/actions';
const moduleLog = log.child({ namespace: 'invoke' });
export const addUserInvokedTextToImageListener = () => {
startAppListening({
predicate: (action): action is ReturnType<typeof userInvoked> =>
userInvoked.match(action) && action.payload === 'txt2img',
effect: (action, { getState, dispatch }) => {
const state = getState();
const graph = buildTextToImageGraph(state);
dispatch(textToImageGraphBuilt(graph));
moduleLog({ data: graph }, 'Text to Image graph built');
dispatch(sessionCreated({ graph }));
},
});
};

View File

@ -1,4 +0,0 @@
import { store } from 'app/store/store';
import { persistStore } from 'redux-persist';
export const persistor = persistStore(store);

View File

@ -1,9 +1,12 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import {
AnyAction,
ThunkDispatch,
combineReducers,
configureStore,
} from '@reduxjs/toolkit';
import { persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // defaults to localStorage for web
import { rememberReducer, rememberEnhancer } from 'redux-remember';
import dynamicMiddlewares from 'redux-dynamic-middlewares';
import { getPersistConfig } from 'redux-deep-persist';
import canvasReducer from 'features/canvas/store/canvasSlice';
import galleryReducer from 'features/gallery/store/gallerySlice';
@ -19,33 +22,17 @@ import hotkeysReducer from 'features/ui/store/hotkeysSlice';
import modelsReducer from 'features/system/store/modelSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import { canvasDenylist } from 'features/canvas/store/canvasPersistDenylist';
import { galleryDenylist } from 'features/gallery/store/galleryPersistDenylist';
import { generationDenylist } from 'features/parameters/store/generationPersistDenylist';
import { lightboxDenylist } from 'features/lightbox/store/lightboxPersistDenylist';
import { modelsDenylist } from 'features/system/store/modelsPersistDenylist';
import { nodesDenylist } from 'features/nodes/store/nodesPersistDenylist';
import { postprocessingDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
import { systemDenylist } from 'features/system/store/systemPersistDenylist';
import { uiDenylist } from 'features/ui/store/uiPersistDenylist';
import { resultsDenylist } from 'features/gallery/store/resultsPersistDenylist';
import { uploadsDenylist } from 'features/gallery/store/uploadsPersistDenylist';
import { listenerMiddleware } from './middleware/listenerMiddleware';
/**
* redux-persist provides an easy and reliable way to persist state across reloads.
*
* While we definitely want generation parameters to be persisted, there are a number
* of things we do *not* want to be persisted across reloads:
* - Gallery/selected image (user may add/delete images from disk between page loads)
* - Connection/processing status
* - Availability of external libraries like ESRGAN/GFPGAN
*
* These can be denylisted in redux-persist.
*
* The necesssary nested persistors with denylists are configured below.
*/
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
const rootReducer = combineReducers({
import { serialize } from './enhancers/reduxRemember/serialize';
import { unserialize } from './enhancers/reduxRemember/unserialize';
import { LOCALSTORAGE_PREFIX } from './constants';
const allReducers = {
canvas: canvasReducer,
gallery: galleryReducer,
generation: generationReducer,
@ -59,65 +46,54 @@ const rootReducer = combineReducers({
ui: uiReducer,
uploads: uploadsReducer,
hotkeys: hotkeysReducer,
});
};
const rootPersistConfig = getPersistConfig({
key: 'root',
storage,
rootReducer,
blacklist: [
...canvasDenylist,
...galleryDenylist,
...generationDenylist,
...lightboxDenylist,
...modelsDenylist,
...nodesDenylist,
...postprocessingDenylist,
// ...resultsDenylist,
'results',
...systemDenylist,
...uiDenylist,
// ...uploadsDenylist,
'uploads',
'hotkeys',
'config',
],
});
const rootReducer = combineReducers(allReducers);
const persistedReducer = persistReducer(rootPersistConfig, rootReducer);
const rememberedRootReducer = rememberReducer(rootReducer);
// TODO: rip the old middleware out when nodes is complete
// export function buildMiddleware() {
// if (import.meta.env.MODE === 'nodes' || import.meta.env.MODE === 'package') {
// return socketMiddleware();
// } else {
// return socketioMiddleware();
// }
// }
const rememberedKeys: (keyof typeof allReducers)[] = [
'canvas',
'gallery',
'generation',
'lightbox',
// 'models',
'nodes',
'postprocessing',
'system',
'ui',
// 'hotkeys',
// 'results',
// 'uploads',
// 'config',
];
export const store = configureStore({
reducer: persistedReducer,
reducer: rememberedRootReducer,
enhancers: [
rememberEnhancer(window.localStorage, rememberedKeys, {
persistDebounce: 300,
serialize,
unserialize,
prefix: LOCALSTORAGE_PREFIX,
}),
],
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
immutableCheck: false,
serializableCheck: false,
}).concat(dynamicMiddlewares),
})
.concat(dynamicMiddlewares)
.prepend(listenerMiddleware.middleware),
devTools: {
// Uncommenting these very rapidly called actions makes the redux dev tools output much more readable
actionsDenylist: [
'canvas/setCursorPosition',
'canvas/setStageCoordinates',
'canvas/setStageScale',
'canvas/setIsDrawing',
'canvas/setBoundingBoxCoordinates',
'canvas/setBoundingBoxDimensions',
'canvas/setIsDrawing',
'canvas/addPointToCurrentLine',
'socket/generatorProgress',
],
actionsDenylist,
actionSanitizer,
stateSanitizer,
trace: true,
},
});
export type AppGetState = typeof store.getState;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunkDispatch = ThunkDispatch<RootState, any, AnyAction>;
export type AppDispatch = typeof store.dispatch;

View File

@ -1,6 +1,6 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { AppDispatch, RootState } from 'app/store/store';
import { AppThunkDispatch, RootState } from 'app/store/store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppDispatch = () => useDispatch<AppThunkDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -0,0 +1,7 @@
import { isEqual } from 'lodash-es';
export const defaultSelectorOptions = {
memoizeOptions: {
resultEqualityCheck: isEqual,
},
};

View File

@ -12,12 +12,10 @@
* 'gfpgan'.
*/
import { GalleryCategory } from 'features/gallery/store/gallerySlice';
import { FacetoolType } from 'features/parameters/store/postprocessingSlice';
import { SelectedImage } from 'features/parameters/store/actions';
import { InvokeTabName } from 'features/ui/store/tabMap';
import { IRect } from 'konva/lib/types';
import { ImageResponseMetadata, ImageType } from 'services/api';
import { AnyInvocation } from 'services/events/types';
import { O } from 'ts-toolbelt';
/**
@ -49,15 +47,20 @@ export type CommonGeneratedImageMetadata = {
postprocessing: null | Array<ESRGANMetadata | FacetoolMetadata>;
sampler:
| 'ddim'
| 'k_dpm_2_a'
| 'k_dpm_2'
| 'k_dpmpp_2_a'
| 'k_dpmpp_2'
| 'k_euler_a'
| 'k_euler'
| 'k_heun'
| 'k_lms'
| 'plms';
| 'ddpm'
| 'deis'
| 'lms'
| 'pndm'
| 'heun'
| 'euler'
| 'euler_k'
| 'euler_a'
| 'kdpm_2'
| 'kdpm_2_a'
| 'dpmpp_2s'
| 'dpmpp_2m'
| 'dpmpp_2m_k'
| 'unipc';
prompt: Prompt;
seed: number;
variations: SeedWeights;
@ -126,6 +129,14 @@ export type Image = {
metadata: ImageResponseMetadata;
};
export const isInvokeAIImage = (obj: Image | SelectedImage): obj is Image => {
if ('url' in obj && 'thumbnail' in obj) {
return true;
}
return false;
};
/**
* Types related to the system status.
*/
@ -270,7 +281,7 @@ export type FoundModelResponse = {
// export type SystemConfigResponse = SystemConfig;
export type ImageResultResponse = Omit<_Image, 'uuid'> & {
export type ImageResultResponse = Omit<Image, 'uuid'> & {
boundingBox?: IRect;
generationMode: InvokeTabName;
};
@ -315,11 +326,11 @@ export type AppFeature =
/**
* A disable-able Stable Diffusion feature
*/
export type StableDiffusionFeature =
| 'noiseConfig'
| 'variations'
export type SDFeature =
| 'noise'
| 'variation'
| 'symmetry'
| 'tiling'
| 'seamless'
| 'hires';
/**
@ -337,6 +348,7 @@ export type AppConfig = {
shouldFetchImages: boolean;
disabledTabs: InvokeTabName[];
disabledFeatures: AppFeature[];
disabledSDFeatures: SDFeature[];
canRestoreDeletedImagesFromBin: boolean;
sd: {
iterations: {

View File

@ -0,0 +1,61 @@
import { ChevronUpIcon } from '@chakra-ui/icons';
import { Box, Collapse, Flex, Spacer, Switch } from '@chakra-ui/react';
import { PropsWithChildren, memo } from 'react';
export type IAIToggleCollapseProps = PropsWithChildren & {
label: string;
isOpen: boolean;
onToggle: () => void;
withSwitch?: boolean;
};
const IAICollapse = (props: IAIToggleCollapseProps) => {
const { label, isOpen, onToggle, children, withSwitch = false } = props;
return (
<Box>
<Flex
onClick={onToggle}
sx={{
alignItems: 'center',
p: 2,
px: 4,
borderTopRadius: 'base',
borderBottomRadius: isOpen ? 0 : 'base',
bg: isOpen ? 'base.750' : 'base.800',
color: 'base.100',
_hover: {
bg: isOpen ? 'base.700' : 'base.750',
},
fontSize: 'sm',
fontWeight: 600,
cursor: 'pointer',
transitionProperty: 'common',
transitionDuration: 'normal',
userSelect: 'none',
}}
>
{label}
<Spacer />
{withSwitch && <Switch isChecked={isOpen} pointerEvents="none" />}
{!withSwitch && (
<ChevronUpIcon
sx={{
w: '1rem',
h: '1rem',
transform: isOpen ? 'rotate(0deg)' : 'rotate(180deg)',
transitionProperty: 'common',
transitionDuration: 'normal',
}}
/>
)}
</Flex>
<Collapse in={isOpen} animateOpacity>
<Box sx={{ p: 4, borderBottomRadius: 'base', bg: 'base.800' }}>
{children}
</Box>
</Collapse>
</Box>
);
};
export default memo(IAICollapse);

View File

@ -27,7 +27,7 @@ const IAIPopover = (props: IAIPopoverProps) => {
return (
<Popover isLazy={isLazy} {...rest}>
<PopoverTrigger>{triggerComponent}</PopoverTrigger>
<PopoverContent>
<PopoverContent shadow="dark-lg">
{hasArrow && <PopoverArrow />}
{children}
</PopoverContent>

View File

@ -0,0 +1,54 @@
import { Badge, Flex } from '@chakra-ui/react';
import { Image } from 'app/types/invokeai';
import { isNumber, isString } from 'lodash-es';
import { useMemo } from 'react';
type ImageMetadataOverlayProps = {
image: Image;
};
const ImageMetadataOverlay = ({ image }: ImageMetadataOverlayProps) => {
const dimensions = useMemo(() => {
if (!isNumber(image.metadata?.width) || isNumber(!image.metadata?.height)) {
return;
}
return `${image.metadata?.width} × ${image.metadata?.height}`;
}, [image.metadata]);
const model = useMemo(() => {
if (!isString(image.metadata?.invokeai?.node?.model)) {
return;
}
return image.metadata?.invokeai?.node?.model;
}, [image.metadata]);
return (
<Flex
sx={{
pointerEvents: 'none',
flexDirection: 'column',
position: 'absolute',
top: 0,
right: 0,
p: 2,
alignItems: 'flex-end',
gap: 2,
}}
>
{dimensions && (
<Badge variant="solid" colorScheme="base">
{dimensions}
</Badge>
)}
{model && (
<Badge variant="solid" colorScheme="base">
{model}
</Badge>
)}
</Flex>
);
};
export default ImageMetadataOverlay;

View File

@ -7,7 +7,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { useCallback } from 'react';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
const ImageToImageSettingsHeader = () => {
const InitialImageButtons = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
@ -18,24 +18,19 @@ const ImageToImageSettingsHeader = () => {
return (
<Flex w="full" alignItems="center">
<Text size="sm" fontWeight={500} color="base.300">
Image to Image
{t('parameters.initialImage')}
</Text>
<Spacer />
<ButtonGroup>
<IAIIconButton
size="sm"
icon={<FaUndo />}
aria-label={t('accessibility.reset')}
onClick={handleResetInitialImage}
/>
<IAIIconButton
size="sm"
icon={<FaUpload />}
aria-label={t('common.upload')}
/>
<IAIIconButton icon={<FaUpload />} aria-label={t('common.upload')} />
</ButtonGroup>
</Flex>
);
};
export default ImageToImageSettingsHeader;
export default InitialImageButtons;

View File

@ -1,36 +0,0 @@
import { Badge, Box, Flex } from '@chakra-ui/react';
import { Image } from 'app/types/invokeai';
type ImageToImageOverlayProps = {
image: Image;
};
const ImageToImageOverlay = ({ image }: ImageToImageOverlayProps) => {
return (
<Box
sx={{
top: 0,
left: 0,
w: 'full',
h: 'full',
position: 'absolute',
}}
>
<Flex
sx={{
position: 'absolute',
top: 0,
right: 0,
p: 2,
alignItems: 'flex-start',
}}
>
<Badge variant="solid" colorScheme="base">
{image.metadata?.width} × {image.metadata?.height}
</Badge>
</Flex>
</Box>
);
};
export default ImageToImageOverlay;

View File

@ -49,7 +49,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
const fileAcceptedCallback = useCallback(
async (file: File) => {
dispatch(imageUploaded({ formData: { file } }));
dispatch(imageUploaded({ imageType: 'uploads', formData: { file } }));
},
[dispatch]
);
@ -124,7 +124,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
return;
}
dispatch(imageUploaded({ formData: { file } }));
dispatch(imageUploaded({ imageType: 'uploads', formData: { file } }));
};
document.addEventListener('paste', pasteImageListener);
return () => {

View File

@ -7,7 +7,7 @@ const SelectImagePlaceholder = () => {
sx={{
w: 'full',
h: 'full',
bg: 'base.800',
// bg: 'base.800',
borderRadius: 'base',
alignItems: 'center',
justifyContent: 'center',

View File

@ -2,6 +2,13 @@ import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { shiftKeyPressed } from 'features/ui/store/hotkeysSlice';
import {
setActiveTab,
toggleGalleryPanel,
toggleParametersPanel,
togglePinGalleryPanel,
togglePinParametersPanel,
} from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es';
import { isHotkeyPressed, useHotkeys } from 'react-hotkeys-hook';
@ -36,4 +43,36 @@ export const useGlobalHotkeys = () => {
{ keyup: true, keydown: true },
[shift]
);
useHotkeys('o', () => {
dispatch(toggleParametersPanel());
});
useHotkeys(['shift+o'], () => {
dispatch(togglePinParametersPanel());
});
useHotkeys('g', () => {
dispatch(toggleGalleryPanel());
});
useHotkeys(['shift+g'], () => {
dispatch(togglePinGalleryPanel());
});
useHotkeys('1', () => {
dispatch(setActiveTab('txt2img'));
});
useHotkeys('2', () => {
dispatch(setActiveTab('img2img'));
});
useHotkeys('3', () => {
dispatch(setActiveTab('unifiedCanvas'));
});
useHotkeys('4', () => {
dispatch(setActiveTab('nodes'));
});
};

View File

@ -0,0 +1,33 @@
export const getImageDataTransparency = (pixels: Uint8ClampedArray) => {
let isFullyTransparent = true;
let isPartiallyTransparent = false;
const len = pixels.length;
let i = 3;
for (i; i < len; i += 4) {
if (pixels[i] === 255) {
isFullyTransparent = false;
} else {
isPartiallyTransparent = true;
}
if (!isFullyTransparent && isPartiallyTransparent) {
return { isFullyTransparent, isPartiallyTransparent };
}
}
return { isFullyTransparent, isPartiallyTransparent };
};
export const areAnyPixelsBlack = (pixels: Uint8ClampedArray) => {
const len = pixels.length;
let i = 0;
for (i; i < len; ) {
if (
pixels[i++] === 0 &&
pixels[i++] === 0 &&
pixels[i++] === 0 &&
pixels[i++] === 255
) {
return true;
}
}
return false;
};

View File

@ -19,6 +19,7 @@ import { InvokeTabName } from 'features/ui/store/tabMap';
import openBase64ImageInTab from './openBase64ImageInTab';
import randomInt from './randomInt';
import { stringToSeedWeightsArray } from './seedWeightPairs';
import { getIsImageDataTransparent, getIsImageDataWhite } from './arrayBuffer';
export type FrontendToBackendParametersConfig = {
generationMode: InvokeTabName;
@ -256,7 +257,7 @@ export const frontendToBackendParameters = (
...boundingBoxDimensions,
};
const maskDataURL = generateMask(
const { dataURL: maskDataURL, imageData: maskImageData } = generateMask(
isMaskEnabled ? objects.filter(isCanvasMaskLine) : [],
boundingBox
);
@ -287,6 +288,17 @@ export const frontendToBackendParameters = (
height: boundingBox.height,
});
const ctx = canvasBaseLayer.getContext();
const imageData = ctx.getImageData(
boundingBox.x + absPos.x,
boundingBox.y + absPos.y,
boundingBox.width,
boundingBox.height
);
const doesBaseHaveTransparency = getIsImageDataTransparent(imageData);
const doesMaskHaveTransparency = getIsImageDataWhite(maskImageData);
if (enableImageDebugging) {
openBase64ImageInTab([
{ base64: maskDataURL, caption: 'mask sent as init_mask' },

View File

@ -34,6 +34,7 @@ import IAICanvasStagingAreaToolbar from './IAICanvasStagingAreaToolbar';
import IAICanvasStatusText from './IAICanvasStatusText';
import IAICanvasBoundingBox from './IAICanvasToolbar/IAICanvasBoundingBox';
import IAICanvasToolPreview from './IAICanvasToolPreview';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
const selector = createSelector(
[canvasSelector, isStagingSelector],
@ -52,6 +53,7 @@ const selector = createSelector(
shouldShowIntermediates,
shouldShowGrid,
shouldRestrictStrokesToBox,
shouldAntialias,
} = canvas;
let stageCursor: string | undefined = 'none';
@ -80,13 +82,10 @@ const selector = createSelector(
tool,
isStaging,
shouldShowIntermediates,
shouldAntialias,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
defaultSelectorOptions
);
const ChakraStage = chakra(Stage, {
@ -106,6 +105,7 @@ const IAICanvas = () => {
tool,
isStaging,
shouldShowIntermediates,
shouldAntialias,
} = useAppSelector(selector);
useCanvasHotkeys();
@ -190,7 +190,7 @@ const IAICanvas = () => {
id="base"
ref={canvasBaseLayerRefCallback}
listening={false}
imageSmoothingEnabled={false}
imageSmoothingEnabled={shouldAntialias}
>
<IAICanvasObjectRenderer />
</Layer>
@ -201,7 +201,7 @@ const IAICanvas = () => {
<Layer>
<IAICanvasBoundingBoxOverlay />
</Layer>
<Layer id="preview" imageSmoothingEnabled={false}>
<Layer id="preview" imageSmoothingEnabled={shouldAntialias}>
{!isStaging && (
<IAICanvasToolPreview
visible={tool !== 'move'}

View File

@ -12,18 +12,20 @@ const selector = createSelector(
[canvasSelector],
(canvas) => {
const {
layerState: {
stagingArea: { images, selectedImageIndex },
},
layerState,
shouldShowStagingImage,
shouldShowStagingOutline,
boundingBoxCoordinates: { x, y },
boundingBoxDimensions: { width, height },
} = canvas;
const { selectedImageIndex, images } = layerState.stagingArea;
return {
currentStagingAreaImage:
images.length > 0 ? images[selectedImageIndex] : undefined,
images.length > 0 && selectedImageIndex !== undefined
? images[selectedImageIndex]
: undefined,
isOnFirstImage: selectedImageIndex === 0,
isOnLastImage: selectedImageIndex === images.length - 1,
shouldShowStagingImage,

View File

@ -6,6 +6,7 @@ import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import { canvasSelector } from 'features/canvas/store/canvasSelectors';
import {
setShouldAntialias,
setShouldAutoSave,
setShouldCropToBoundingBoxOnSave,
setShouldDarkenOutsideBoundingBox,
@ -36,6 +37,7 @@ export const canvasControlsSelector = createSelector(
shouldShowIntermediates,
shouldSnapToGrid,
shouldRestrictStrokesToBox,
shouldAntialias,
} = canvas;
return {
@ -47,6 +49,7 @@ export const canvasControlsSelector = createSelector(
shouldShowIntermediates,
shouldSnapToGrid,
shouldRestrictStrokesToBox,
shouldAntialias,
};
},
{
@ -69,6 +72,7 @@ const IAICanvasSettingsButtonPopover = () => {
shouldShowIntermediates,
shouldSnapToGrid,
shouldRestrictStrokesToBox,
shouldAntialias,
} = useAppSelector(canvasControlsSelector);
useHotkeys(
@ -148,6 +152,12 @@ const IAICanvasSettingsButtonPopover = () => {
dispatch(setShouldShowCanvasDebugInfo(e.target.checked))
}
/>
<IAICheckbox
label={t('unifiedCanvas.antialiasing')}
isChecked={shouldAntialias}
onChange={(e) => dispatch(setShouldAntialias(e.target.checked))}
/>
<ClearCanvasHistoryButtonModal />
<EmptyTempFolderButtonModal />
</Flex>

View File

@ -9,6 +9,12 @@ const itemsToDenylist: (keyof CanvasState)[] = [
'doesCanvasNeedScaling',
];
export const canvasPersistDenylist: (keyof CanvasState)[] = [
'cursorPosition',
'isCanvasInitialized',
'doesCanvasNeedScaling',
];
export const canvasDenylist = itemsToDenylist.map(
(denylistItem) => `canvas.${denylistItem}`
);

View File

@ -38,7 +38,7 @@ export const initialLayerState: CanvasLayerState = {
},
};
const initialCanvasState: CanvasState = {
export const initialCanvasState: CanvasState = {
boundingBoxCoordinates: { x: 0, y: 0 },
boundingBoxDimensions: { width: 512, height: 512 },
boundingBoxPreviewFill: { r: 0, g: 0, b: 0, a: 0.5 },
@ -66,6 +66,7 @@ const initialCanvasState: CanvasState = {
minimumStageScale: 1,
pastLayerStates: [],
scaledBoundingBoxDimensions: { width: 512, height: 512 },
shouldAntialias: true,
shouldAutoSave: false,
shouldCropToBoundingBoxOnSave: false,
shouldDarkenOutsideBoundingBox: false,
@ -156,22 +157,20 @@ export const canvasSlice = createSlice({
setCursorPosition: (state, action: PayloadAction<Vector2d | null>) => {
state.cursorPosition = action.payload;
},
setInitialCanvasImage: (state, action: PayloadAction<InvokeAI._Image>) => {
setInitialCanvasImage: (state, action: PayloadAction<InvokeAI.Image>) => {
const image = action.payload;
const { width, height } = image.metadata;
const { stageDimensions } = state;
const newBoundingBoxDimensions = {
width: roundDownToMultiple(clamp(image.width, 64, 512), 64),
height: roundDownToMultiple(clamp(image.height, 64, 512), 64),
width: roundDownToMultiple(clamp(width, 64, 512), 64),
height: roundDownToMultiple(clamp(height, 64, 512), 64),
};
const newBoundingBoxCoordinates = {
x: roundToMultiple(
image.width / 2 - newBoundingBoxDimensions.width / 2,
64
),
x: roundToMultiple(width / 2 - newBoundingBoxDimensions.width / 2, 64),
y: roundToMultiple(
image.height / 2 - newBoundingBoxDimensions.height / 2,
height / 2 - newBoundingBoxDimensions.height / 2,
64
),
};
@ -196,8 +195,8 @@ export const canvasSlice = createSlice({
layer: 'base',
x: 0,
y: 0,
width: image.width,
height: image.height,
width: width,
height: height,
image: image,
},
],
@ -208,8 +207,8 @@ export const canvasSlice = createSlice({
const newScale = calculateScale(
stageDimensions.width,
stageDimensions.height,
image.width,
image.height,
width,
height,
STAGE_PADDING_PERCENTAGE
);
@ -218,8 +217,8 @@ export const canvasSlice = createSlice({
stageDimensions.height,
0,
0,
image.width,
image.height,
width,
height,
newScale
);
state.stageScale = newScale;
@ -287,16 +286,28 @@ export const canvasSlice = createSlice({
setIsMoveStageKeyHeld: (state, action: PayloadAction<boolean>) => {
state.isMoveStageKeyHeld = action.payload;
},
addImageToStagingArea: (
canvasSessionIdChanged: (state, action: PayloadAction<string>) => {
state.layerState.stagingArea.sessionId = action.payload;
},
stagingAreaInitialized: (
state,
action: PayloadAction<{
boundingBox: IRect;
image: InvokeAI._Image;
}>
action: PayloadAction<{ sessionId: string; boundingBox: IRect }>
) => {
const { boundingBox, image } = action.payload;
const { sessionId, boundingBox } = action.payload;
if (!boundingBox || !image) return;
state.layerState.stagingArea = {
boundingBox,
sessionId,
images: [],
selectedImageIndex: -1,
};
},
addImageToStagingArea: (state, action: PayloadAction<InvokeAI.Image>) => {
const image = action.payload;
if (!image || !state.layerState.stagingArea.boundingBox) {
return;
}
state.pastLayerStates.push(cloneDeep(state.layerState));
@ -307,7 +318,7 @@ export const canvasSlice = createSlice({
state.layerState.stagingArea.images.push({
kind: 'image',
layer: 'base',
...boundingBox,
...state.layerState.stagingArea.boundingBox,
image,
});
@ -323,9 +334,7 @@ export const canvasSlice = createSlice({
state.pastLayerStates.shift();
}
state.layerState.stagingArea = {
...initialLayerState.stagingArea,
};
state.layerState.stagingArea = { ...initialLayerState.stagingArea };
state.futureLayerStates = [];
state.shouldShowStagingOutline = true;
@ -663,6 +672,10 @@ export const canvasSlice = createSlice({
}
},
nextStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const currentIndex = state.layerState.stagingArea.selectedImageIndex;
const length = state.layerState.stagingArea.images.length;
@ -672,6 +685,10 @@ export const canvasSlice = createSlice({
);
},
prevStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const currentIndex = state.layerState.stagingArea.selectedImageIndex;
state.layerState.stagingArea.selectedImageIndex = Math.max(
@ -680,6 +697,10 @@ export const canvasSlice = createSlice({
);
},
commitStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
return;
}
const { images, selectedImageIndex } = state.layerState.stagingArea;
state.pastLayerStates.push(cloneDeep(state.layerState));
@ -776,6 +797,9 @@ export const canvasSlice = createSlice({
setShouldRestrictStrokesToBox: (state, action: PayloadAction<boolean>) => {
state.shouldRestrictStrokesToBox = action.payload;
},
setShouldAntialias: (state, action: PayloadAction<boolean>) => {
state.shouldAntialias = action.payload;
},
setShouldCropToBoundingBoxOnSave: (
state,
action: PayloadAction<boolean>
@ -885,6 +909,9 @@ export const {
undo,
setScaledBoundingBoxDimensions,
setShouldRestrictStrokesToBox,
stagingAreaInitialized,
canvasSessionIdChanged,
setShouldAntialias,
} = canvasSlice.actions;
export default canvasSlice.reducer;

View File

@ -37,7 +37,7 @@ export type CanvasImage = {
y: number;
width: number;
height: number;
image: InvokeAI._Image;
image: InvokeAI.Image;
};
export type CanvasMaskLine = {
@ -90,9 +90,16 @@ export type CanvasLayerState = {
stagingArea: {
images: CanvasImage[];
selectedImageIndex: number;
sessionId?: string;
boundingBox?: IRect;
};
};
export type CanvasSession = {
sessionId: string;
boundingBox: IRect;
};
// type guards
export const isCanvasMaskLine = (obj: CanvasObject): obj is CanvasMaskLine =>
obj.kind === 'line' && obj.layer === 'mask';
@ -125,7 +132,7 @@ export interface CanvasState {
cursorPosition: Vector2d | null;
doesCanvasNeedScaling: boolean;
futureLayerStates: CanvasLayerState[];
intermediateImage?: InvokeAI._Image;
intermediateImage?: InvokeAI.Image;
isCanvasInitialized: boolean;
isDrawing: boolean;
isMaskEnabled: boolean;
@ -142,6 +149,7 @@ export interface CanvasState {
minimumStageScale: number;
pastLayerStates: CanvasLayerState[];
scaledBoundingBoxDimensions: Dimensions;
shouldAntialias: boolean;
shouldAutoSave: boolean;
shouldCropToBoundingBoxOnSave: boolean;
shouldDarkenOutsideBoundingBox: boolean;

View File

@ -0,0 +1,13 @@
/**
* Gets a Blob from a canvas.
*/
export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> =>
new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject('Unable to create Blob');
});
});

View File

@ -0,0 +1,29 @@
/**
* Gets an ImageData object from an image dataURL by drawing it to a canvas.
*/
export const dataURLToImageData = async (
dataURL: string,
width: number,
height: number
): Promise<ImageData> =>
new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = new Image();
if (!ctx) {
canvas.remove();
reject('Unable to get context');
return;
}
image.onload = function () {
ctx.drawImage(image, 0, 0);
canvas.remove();
resolve(ctx.getImageData(0, 0, width, height));
};
image.src = dataURL;
});

View File

@ -1,6 +1,110 @@
// import { CanvasMaskLine } from 'features/canvas/store/canvasTypes';
// import Konva from 'konva';
// import { Stage } from 'konva/lib/Stage';
// import { IRect } from 'konva/lib/types';
// /**
// * Generating a mask image from InpaintingCanvas.tsx is not as simple
// * as calling toDataURL() on the canvas, because the mask may be represented
// * by colored lines or transparency, or the user may have inverted the mask
// * display.
// *
// * So we need to regenerate the mask image by creating an offscreen canvas,
// * drawing the mask and compositing everything correctly to output a valid
// * mask image.
// */
// export const getStageDataURL = (stage: Stage, boundingBox: IRect): string => {
// // create an offscreen canvas and add the mask to it
// // const { stage, offscreenContainer } = buildMaskStage(lines, boundingBox);
// const dataURL = stage.toDataURL({ ...boundingBox });
// // const imageData = stage
// // .toCanvas()
// // .getContext('2d')
// // ?.getImageData(
// // boundingBox.x,
// // boundingBox.y,
// // boundingBox.width,
// // boundingBox.height
// // );
// // offscreenContainer.remove();
// // return { dataURL, imageData };
// return dataURL;
// };
// export const getStageImageData = (
// stage: Stage,
// boundingBox: IRect
// ): ImageData | undefined => {
// const imageData = stage
// .toCanvas()
// .getContext('2d')
// ?.getImageData(
// boundingBox.x,
// boundingBox.y,
// boundingBox.width,
// boundingBox.height
// );
// return imageData;
// };
// export const buildMaskStage = (
// lines: CanvasMaskLine[],
// boundingBox: IRect
// ): { stage: Stage; offscreenContainer: HTMLDivElement } => {
// // create an offscreen canvas and add the mask to it
// const { width, height } = boundingBox;
// const offscreenContainer = document.createElement('div');
// const stage = new Konva.Stage({
// container: offscreenContainer,
// width: width,
// height: height,
// });
// const baseLayer = new Konva.Layer();
// const maskLayer = new Konva.Layer();
// // composite the image onto the mask layer
// baseLayer.add(
// new Konva.Rect({
// ...boundingBox,
// fill: 'white',
// })
// );
// lines.forEach((line) =>
// maskLayer.add(
// new Konva.Line({
// points: line.points,
// stroke: 'black',
// strokeWidth: line.strokeWidth * 2,
// tension: 0,
// lineCap: 'round',
// lineJoin: 'round',
// shadowForStrokeEnabled: false,
// globalCompositeOperation:
// line.tool === 'brush' ? 'source-over' : 'destination-out',
// })
// )
// );
// stage.add(baseLayer);
// stage.add(maskLayer);
// return { stage, offscreenContainer };
// };
import { CanvasMaskLine } from 'features/canvas/store/canvasTypes';
import Konva from 'konva';
import { IRect } from 'konva/lib/types';
import { canvasToBlob } from './canvasToBlob';
/**
* Generating a mask image from InpaintingCanvas.tsx is not as simple
@ -12,7 +116,7 @@ import { IRect } from 'konva/lib/types';
* drawing the mask and compositing everything correctly to output a valid
* mask image.
*/
const generateMask = (lines: CanvasMaskLine[], boundingBox: IRect): string => {
const generateMask = async (lines: CanvasMaskLine[], boundingBox: IRect) => {
// create an offscreen canvas and add the mask to it
const { width, height } = boundingBox;
@ -54,11 +158,13 @@ const generateMask = (lines: CanvasMaskLine[], boundingBox: IRect): string => {
stage.add(baseLayer);
stage.add(maskLayer);
const dataURL = stage.toDataURL({ ...boundingBox });
const maskDataURL = stage.toDataURL(boundingBox);
const maskBlob = await canvasToBlob(stage.toCanvas(boundingBox));
offscreenContainer.remove();
return dataURL;
return { maskDataURL, maskBlob };
};
export default generateMask;

View File

@ -0,0 +1,128 @@
import { RootState } from 'app/store/store';
import { getCanvasBaseLayer, getCanvasStage } from './konvaInstanceProvider';
import { isCanvasMaskLine } from '../store/canvasTypes';
import { log } from 'app/logging/useLogger';
import {
areAnyPixelsBlack,
getImageDataTransparency,
} from 'common/util/arrayBuffer';
import openBase64ImageInTab from 'common/util/openBase64ImageInTab';
import generateMask from './generateMask';
import { dataURLToImageData } from './dataURLToImageData';
import { canvasToBlob } from './canvasToBlob';
const moduleLog = log.child({ namespace: 'getCanvasDataURLs' });
export const getCanvasData = async (state: RootState) => {
const canvasBaseLayer = getCanvasBaseLayer();
const canvasStage = getCanvasStage();
if (!canvasBaseLayer || !canvasStage) {
moduleLog.error('Unable to find canvas / stage');
return;
}
const {
layerState: { objects },
boundingBoxCoordinates,
boundingBoxDimensions,
stageScale,
isMaskEnabled,
shouldPreserveMaskedArea,
boundingBoxScaleMethod: boundingBoxScale,
scaledBoundingBoxDimensions,
} = state.canvas;
const boundingBox = {
...boundingBoxCoordinates,
...boundingBoxDimensions,
};
// generationParameters.fit = false;
// generationParameters.strength = img2imgStrength;
// generationParameters.invert_mask = shouldPreserveMaskedArea;
// generationParameters.bounding_box = boundingBox;
const tempScale = canvasBaseLayer.scale();
canvasBaseLayer.scale({
x: 1 / stageScale,
y: 1 / stageScale,
});
const absPos = canvasBaseLayer.getAbsolutePosition();
const offsetBoundingBox = {
x: boundingBox.x + absPos.x,
y: boundingBox.y + absPos.y,
width: boundingBox.width,
height: boundingBox.height,
};
const baseDataURL = canvasBaseLayer.toDataURL(offsetBoundingBox);
const baseBlob = await canvasToBlob(
canvasBaseLayer.toCanvas(offsetBoundingBox)
);
canvasBaseLayer.scale(tempScale);
const { maskDataURL, maskBlob } = await generateMask(
isMaskEnabled ? objects.filter(isCanvasMaskLine) : [],
boundingBox
);
const baseImageData = await dataURLToImageData(
baseDataURL,
boundingBox.width,
boundingBox.height
);
const maskImageData = await dataURLToImageData(
maskDataURL,
boundingBox.width,
boundingBox.height
);
const {
isPartiallyTransparent: baseIsPartiallyTransparent,
isFullyTransparent: baseIsFullyTransparent,
} = getImageDataTransparency(baseImageData.data);
const doesMaskHaveBlackPixels = areAnyPixelsBlack(maskImageData.data);
if (state.system.enableImageDebugging) {
openBase64ImageInTab([
{ base64: maskDataURL, caption: 'mask b64' },
{ base64: baseDataURL, caption: 'image b64' },
]);
}
// generationParameters.init_img = imageDataURL;
// generationParameters.progress_images = false;
// if (boundingBoxScale !== 'none') {
// generationParameters.inpaint_width = scaledBoundingBoxDimensions.width;
// generationParameters.inpaint_height = scaledBoundingBoxDimensions.height;
// }
// generationParameters.seam_size = seamSize;
// generationParameters.seam_blur = seamBlur;
// generationParameters.seam_strength = seamStrength;
// generationParameters.seam_steps = seamSteps;
// generationParameters.tile_size = tileSize;
// generationParameters.infill_method = infillMethod;
// generationParameters.force_outpaint = false;
return {
baseDataURL,
baseBlob,
maskDataURL,
maskBlob,
baseIsPartiallyTransparent,
baseIsFullyTransparent,
doesMaskHaveBlackPixels,
};
};

View File

@ -1,12 +1,17 @@
import { createSelector } from '@reduxjs/toolkit';
import { get, isEqual, isNumber, isString } from 'lodash-es';
import { isEqual, isString } from 'lodash-es';
import {
ButtonGroup,
Flex,
FlexProps,
FormControl,
IconButton,
Link,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
useDisclosure,
useToast,
} from '@chakra-ui/react';
@ -15,21 +20,12 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { GalleryState } from 'features/gallery/store/gallerySlice';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice';
import FaceRestoreSettings from 'features/parameters/components/AdvancedParameters/FaceRestore/FaceRestoreSettings';
import UpscaleSettings from 'features/parameters/components/AdvancedParameters/Upscale/UpscaleSettings';
import {
initialImageSelected,
setAllParameters,
// setInitialImage,
setSeed,
} from 'features/parameters/store/generationSlice';
import { postprocessingSelector } from 'features/parameters/store/postprocessingSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import { SystemState } from 'features/system/store/systemSlice';
import {
activeTabNameSelector,
uiSelector,
@ -56,6 +52,7 @@ import {
FaShare,
FaShareAlt,
FaTrash,
FaWrench,
} from 'react-icons/fa';
import {
gallerySelector,
@ -66,8 +63,13 @@ import { useCallback } from 'react';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { useGetUrl } from 'common/util/getUrl';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { imageDeleted } from 'services/thunks/image';
import { useParameters } from 'features/parameters/hooks/useParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { requestedImageDeletion } from '../store/actions';
import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceRestore/FaceRestoreSettings';
import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings';
import { allParametersSet } from 'features/parameters/store/generationSlice';
import DeleteImageButton from './ImageActionButtons/DeleteImageButton';
const currentImageButtonsSelector = createSelector(
[
@ -150,6 +152,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
} = useAppSelector(currentImageButtonsSelector);
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled;
const isFaceRestoreEnabled = useFeatureStatus('faceRestore').isFeatureEnabled;
@ -164,40 +167,59 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const toast = useToast();
const { t } = useTranslation();
const { recallPrompt, recallSeed, sendToImageToImage } = useParameters();
const { recallPrompt, recallSeed, recallAllParameters } = useParameters();
const handleCopyImage = useCallback(async () => {
if (!image?.url) {
return;
}
// const handleCopyImage = useCallback(async () => {
// if (!image?.url) {
// return;
// }
const url = getUrl(image.url);
// const url = getUrl(image.url);
if (!url) {
return;
}
// if (!url) {
// return;
// }
const blob = await fetch(url).then((res) => res.blob());
const data = [new ClipboardItem({ [blob.type]: blob })];
// const blob = await fetch(url).then((res) => res.blob());
// const data = [new ClipboardItem({ [blob.type]: blob })];
await navigator.clipboard.write(data);
// await navigator.clipboard.write(data);
toast({
title: t('toast.imageCopied'),
status: 'success',
duration: 2500,
isClosable: true,
});
}, [getUrl, t, image?.url, toast]);
// toast({
// title: t('toast.imageCopied'),
// status: 'success',
// duration: 2500,
// isClosable: true,
// });
// }, [getUrl, t, image?.url, toast]);
const handleCopyImageLink = useCallback(() => {
const url = image
? shouldTransformUrls
? getUrl(image.url)
: window.location.toString() + image.url
: '';
const getImageUrl = () => {
if (!image) {
return;
}
if (shouldTransformUrls) {
return getUrl(image.url);
}
if (image.url.startsWith('http')) {
return image.url;
}
return window.location.toString() + image.url;
};
const url = getImageUrl();
if (!url) {
toast({
title: t('toast.problemCopyingImageLink'),
status: 'error',
duration: 2500,
isClosable: true,
});
return;
}
@ -216,39 +238,15 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
}, [dispatch, shouldHidePreview]);
const handleClickUseAllParameters = useCallback(() => {
if (!image) return;
// selectedImage.metadata &&
// dispatch(setAllParameters(selectedImage.metadata));
// if (selectedImage.metadata?.image.type === 'img2img') {
// dispatch(setActiveTab('img2img'));
// } else if (selectedImage.metadata?.image.type === 'txt2img') {
// dispatch(setActiveTab('txt2img'));
// }
}, [image]);
recallAllParameters(image);
}, [image, recallAllParameters]);
useHotkeys(
'a',
() => {
const type = image?.metadata?.invokeai?.node?.types;
if (isString(type) && ['txt2img', 'img2img'].includes(type)) {
handleClickUseAllParameters();
toast({
title: t('toast.parametersSet'),
status: 'success',
duration: 2500,
isClosable: true,
});
} else {
toast({
title: t('toast.parametersNotSet'),
description: t('toast.parametersNotSetDesc'),
status: 'error',
duration: 2500,
isClosable: true,
});
}
handleClickUseAllParameters;
},
[image]
[image, recallAllParameters]
);
const handleUseSeed = useCallback(() => {
@ -264,8 +262,8 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
useHotkeys('p', handleUsePrompt, [image]);
const handleSendToImageToImage = useCallback(() => {
sendToImageToImage(image);
}, [image, sendToImageToImage]);
dispatch(initialImageSelected(image));
}, [dispatch, image]);
useHotkeys('shift+i', handleSendToImageToImage, [image]);
@ -375,7 +373,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(imageDeleted({ imageType: image.type, imageName: image.name }));
dispatch(requestedImageDeletion(image));
}
}, [image, canDeleteImage, dispatch]);
@ -432,21 +430,23 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
>
{t('parameters.sendToImg2Img')}
</IAIButton>
<IAIButton
size="sm"
onClick={handleSendToCanvas}
leftIcon={<FaShare />}
>
{t('parameters.sendToUnifiedCanvas')}
</IAIButton>
{isCanvasEnabled && (
<IAIButton
size="sm"
onClick={handleSendToCanvas}
leftIcon={<FaShare />}
>
{t('parameters.sendToUnifiedCanvas')}
</IAIButton>
)}
<IAIButton
{/* <IAIButton
size="sm"
onClick={handleCopyImage}
leftIcon={<FaCopy />}
>
{t('parameters.copyImage')}
</IAIButton>
</IAIButton> */}
<IAIButton
size="sm"
onClick={handleCopyImageLink}
@ -462,7 +462,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
</Link>
</Flex>
</IAIPopover>
<IAIIconButton
{/* <IAIIconButton
icon={shouldHidePreview ? <FaEyeSlash /> : <FaEye />}
tooltip={
!shouldHidePreview
@ -476,7 +476,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
}
isChecked={shouldHidePreview}
onClick={handlePreviewVisibility}
/>
/> */}
{isLightboxEnabled && (
<IAIIconButton
icon={<FaExpand />}
@ -518,8 +518,8 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={
!['txt2img', 'img2img'].includes(
image?.metadata?.sd_metadata?.type
!['txt2img', 'img2img', 'inpaint'].includes(
String(image?.metadata?.invokeai?.node?.type)
)
}
onClick={handleClickUseAllParameters}
@ -602,22 +602,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
/>
</ButtonGroup>
<IAIIconButton
onClick={handleInitiateDelete}
icon={<FaTrash />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={!image || !isConnected}
colorScheme="error"
/>
<ButtonGroup isAttached={true}>
<DeleteImageButton image={image} />
</ButtonGroup>
</Flex>
{image && (
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
)}
</>
);
};

View File

@ -2,26 +2,35 @@ import { Box, Flex, Image } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useGetUrl } from 'common/util/getUrl';
import { systemSelector } from 'features/system/store/systemSelectors';
import { uiSelector } from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash-es';
import { selectedImageSelector } from '../store/gallerySelectors';
import CurrentImageFallback from './CurrentImageFallback';
import { gallerySelector } from '../store/gallerySelectors';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
import NextPrevImageButtons from './NextPrevImageButtons';
import CurrentImageHidden from './CurrentImageHidden';
import { memo } from 'react';
import { DragEvent, memo, useCallback } from 'react';
import { systemSelector } from 'features/system/store/systemSelectors';
import ImageFallbackSpinner from './ImageFallbackSpinner';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
export const imagesSelector = createSelector(
[uiSelector, selectedImageSelector, systemSelector],
(ui, selectedImage, system) => {
const { shouldShowImageDetails, shouldHidePreview } = ui;
[uiSelector, gallerySelector, systemSelector],
(ui, gallery, system) => {
const {
shouldShowImageDetails,
shouldHidePreview,
shouldShowProgressInViewer,
} = ui;
const { selectedImage } = gallery;
const { progressImage, shouldAntialiasProgressImage } = system;
return {
shouldShowImageDetails,
shouldHidePreview,
image: selectedImage,
progressImage,
shouldShowProgressInViewer,
shouldAntialiasProgressImage,
};
},
{
@ -32,26 +41,43 @@ export const imagesSelector = createSelector(
);
const CurrentImagePreview = () => {
const { shouldShowImageDetails, image, shouldHidePreview } =
useAppSelector(imagesSelector);
const {
shouldShowImageDetails,
image,
shouldHidePreview,
progressImage,
shouldShowProgressInViewer,
shouldAntialiasProgressImage,
} = useAppSelector(imagesSelector);
const { getUrl } = useGetUrl();
const handleDragStart = useCallback(
(e: DragEvent<HTMLDivElement>) => {
if (!image) {
return;
}
e.dataTransfer.setData('invokeai/imageName', image.name);
e.dataTransfer.setData('invokeai/imageType', image.type);
e.dataTransfer.effectAllowed = 'move';
},
[image]
);
return (
<Flex
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
}}
>
{image && (
{progressImage && shouldShowProgressInViewer ? (
<Image
src={shouldHidePreview ? undefined : getUrl(image.url)}
width={image.metadata.width}
height={image.metadata.height}
fallback={shouldHidePreview ? <CurrentImageHidden /> : undefined}
src={progressImage.dataURL}
width={progressImage.width}
height={progressImage.height}
sx={{
objectFit: 'contain',
maxWidth: '100%',
@ -59,8 +85,29 @@ const CurrentImagePreview = () => {
height: 'auto',
position: 'absolute',
borderRadius: 'base',
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
}}
/>
) : (
image && (
<>
<Image
src={getUrl(image.url)}
fallbackStrategy="beforeLoadOrError"
fallback={<ImageFallbackSpinner />}
onDragStart={handleDragStart}
sx={{
objectFit: 'contain',
maxWidth: '100%',
maxHeight: '100%',
height: 'auto',
position: 'absolute',
borderRadius: 'base',
}}
/>
<ImageMetadataOverlay image={image} />
</>
)
)}
{shouldShowImageDetails && image && 'metadata' in image && (
<Box

View File

@ -0,0 +1,70 @@
import { Flex, Image, Spinner } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { systemSelector } from 'features/system/store/systemSelectors';
import { memo } from 'react';
import { gallerySelector } from '../store/gallerySelectors';
const selector = createSelector(
[systemSelector, gallerySelector],
(system, gallery) => {
const { shouldUseSingleGalleryColumn, galleryImageObjectFit } = gallery;
const { progressImage, shouldAntialiasProgressImage } = system;
return {
progressImage,
shouldUseSingleGalleryColumn,
galleryImageObjectFit,
shouldAntialiasProgressImage,
};
},
defaultSelectorOptions
);
const GalleryProgressImage = () => {
const {
progressImage,
shouldUseSingleGalleryColumn,
galleryImageObjectFit,
shouldAntialiasProgressImage,
} = useAppSelector(selector);
if (!progressImage) {
return null;
}
return (
<Flex
sx={{
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
aspectRatio: '1/1',
position: 'relative',
}}
>
<Image
draggable={false}
src={progressImage.dataURL}
width={progressImage.width}
height={progressImage.height}
sx={{
objectFit: shouldUseSingleGalleryColumn
? 'contain'
: galleryImageObjectFit,
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 'base',
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
}}
/>
<Spinner sx={{ position: 'absolute', top: 1, right: 1, opacity: 0.7 }} />
</Flex>
);
};
export default memo(GalleryProgressImage);

View File

@ -5,19 +5,20 @@ import {
Image,
MenuItem,
MenuList,
Skeleton,
useDisclosure,
useTheme,
useToast,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { DragEvent, memo, useCallback, useState } from 'react';
import { DragEvent, MouseEvent, memo, useCallback, useState } from 'react';
import { FaCheck, FaExpand, FaImage, FaShare, FaTrash } from 'react-icons/fa';
import DeleteImageModal from './DeleteImageModal';
import { ContextMenu } from 'chakra-ui-contextmenu';
import * as InvokeAI from 'app/types/invokeai';
import { resizeAndScaleCanvas } from 'features/canvas/store/canvasSlice';
import {
resizeAndScaleCanvas,
setInitialCanvasImage,
} from 'features/canvas/store/canvasSlice';
import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useTranslation } from 'react-i18next';
@ -25,7 +26,6 @@ import IAIIconButton from 'common/components/IAIIconButton';
import { useGetUrl } from 'common/util/getUrl';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { imageDeleted } from 'services/thunks/image';
import { createSelector } from '@reduxjs/toolkit';
import { systemSelector } from 'features/system/store/systemSelectors';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
@ -33,6 +33,8 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash-es';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useParameters } from 'features/parameters/hooks/useParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { requestedImageDeletion } from '../store/actions';
export const selector = createSelector(
[gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector],
@ -94,16 +96,19 @@ const HoverableImage = memo((props: HoverableImageProps) => {
} = useDisclosure();
const { image, isSelected } = props;
const { url, thumbnail, name, metadata } = image;
const { url, thumbnail, name } = image;
const { getUrl } = useGetUrl();
const [isHovered, setIsHovered] = useState<boolean>(false);
const toast = useToast();
const { direction } = useTheme();
const { t } = useTranslation();
const { isFeatureEnabled: isLightboxEnabled } = useFeatureStatus('lightbox');
const { recallSeed, recallPrompt, sendToImageToImage, recallInitialImage } =
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const { recallSeed, recallPrompt, recallInitialImage, recallAllParameters } =
useParameters();
const handleMouseOver = () => setIsHovered(true);
@ -112,18 +117,22 @@ const HoverableImage = memo((props: HoverableImageProps) => {
// Immediately deletes an image
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(imageDeleted({ imageType: image.type, imageName: image.name }));
dispatch(requestedImageDeletion(image));
}
}, [dispatch, image, canDeleteImage]);
// Opens the alert dialog to check if user is sure they want to delete
const handleInitiateDelete = useCallback(() => {
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
}, [handleDelete, onDeleteDialogOpen, shouldConfirmOnDelete]);
const handleInitiateDelete = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
},
[handleDelete, onDeleteDialogOpen, shouldConfirmOnDelete]
);
const handleSelectImage = useCallback(() => {
dispatch(imageSelected(image));
@ -148,8 +157,8 @@ const HoverableImage = memo((props: HoverableImageProps) => {
}, [image, recallSeed]);
const handleSendToImageToImage = useCallback(() => {
sendToImageToImage(image);
}, [image, sendToImageToImage]);
dispatch(initialImageSelected(image));
}, [dispatch, image]);
const handleRecallInitialImage = useCallback(() => {
recallInitialImage(image.metadata.invokeai?.node?.image);
@ -159,7 +168,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
* TODO: the rest of these
*/
const handleSendToCanvas = () => {
// dispatch(setInitialCanvasImage(image));
dispatch(setInitialCanvasImage(image));
dispatch(resizeAndScaleCanvas());
@ -175,16 +184,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
});
};
const handleUseAllParameters = () => {
// metadata.invokeai?.node &&
// dispatch(setAllParameters(metadata.invokeai?.node));
// toast({
// title: t('toast.parametersSet'),
// status: 'success',
// duration: 2500,
// isClosable: true,
// });
};
const handleUseAllParameters = useCallback(() => {
recallAllParameters(image);
}, [image, recallAllParameters]);
const handleLightBox = () => {
// dispatch(setCurrentImage(image));
@ -238,7 +240,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseAllParameters}
isDisabled={
!['txt2img', 'img2img'].includes(
!['txt2img', 'img2img', 'inpaint'].includes(
String(image?.metadata?.invokeai?.node?.type)
)
}
@ -251,9 +253,11 @@ const HoverableImage = memo((props: HoverableImageProps) => {
>
{t('parameters.sendToImg2Img')}
</MenuItem>
<MenuItem icon={<FaShare />} onClickCapture={handleSendToCanvas}>
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
{isCanvasEnabled && (
<MenuItem icon={<FaShare />} onClickCapture={handleSendToCanvas}>
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
<MenuItem icon={<FaTrash />} onClickCapture={onDeleteDialogOpen}>
{t('gallery.deleteImage')}
</MenuItem>
@ -279,6 +283,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
h: 'full',
transition: 'transform 0.2s ease-out',
aspectRatio: '1/1',
cursor: 'pointer',
}}
>
<Image
@ -315,6 +320,8 @@ const HoverableImage = memo((props: HoverableImageProps) => {
sx={{
width: '50%',
height: '50%',
maxWidth: '4rem',
maxHeight: '4rem',
fill: 'ok.500',
}}
/>

View File

@ -0,0 +1,92 @@
import { createSelector } from '@reduxjs/toolkit';
import { useDisclosure } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { systemSelector } from 'features/system/store/systemSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
import { memo, useCallback } from 'react';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import DeleteImageModal from '../DeleteImageModal';
import { requestedImageDeletion } from 'features/gallery/store/actions';
import { Image } from 'app/types/invokeai';
const selector = createSelector(
[systemSelector],
(system) => {
const { isProcessing, isConnected, shouldConfirmOnDelete } = system;
return {
canDeleteImage: isConnected && !isProcessing,
shouldConfirmOnDelete,
isProcessing,
isConnected,
};
},
defaultSelectorOptions
);
type DeleteImageButtonProps = {
image: Image | undefined;
};
const DeleteImageButton = (props: DeleteImageButtonProps) => {
const { image } = props;
const dispatch = useAppDispatch();
const { isProcessing, isConnected, canDeleteImage, shouldConfirmOnDelete } =
useAppSelector(selector);
const {
isOpen: isDeleteDialogOpen,
onOpen: onDeleteDialogOpen,
onClose: onDeleteDialogClose,
} = useDisclosure();
const { t } = useTranslation();
const handleDelete = useCallback(() => {
if (canDeleteImage && image) {
dispatch(requestedImageDeletion(image));
}
}, [image, canDeleteImage, dispatch]);
const handleInitiateDelete = useCallback(() => {
if (shouldConfirmOnDelete) {
onDeleteDialogOpen();
} else {
handleDelete();
}
}, [shouldConfirmOnDelete, onDeleteDialogOpen, handleDelete]);
useHotkeys('delete', handleInitiateDelete, [
image,
shouldConfirmOnDelete,
isConnected,
isProcessing,
]);
return (
<>
<IAIIconButton
onClick={handleInitiateDelete}
icon={<FaTrash />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={!image || !isConnected}
colorScheme="error"
/>
{image && (
<DeleteImageModal
isOpen={isDeleteDialogOpen}
onClose={onDeleteDialogClose}
handleDelete={handleDelete}
/>
)}
</>
);
};
export default memo(DeleteImageButton);

View File

@ -1,8 +1,8 @@
import { Flex, Spinner, SpinnerProps } from '@chakra-ui/react';
type CurrentImageFallbackProps = SpinnerProps;
type ImageFallbackSpinnerProps = SpinnerProps;
const CurrentImageFallback = (props: CurrentImageFallbackProps) => {
const ImageFallbackSpinner = (props: ImageFallbackSpinnerProps) => {
const { size = 'xl', ...rest } = props;
return (
@ -21,4 +21,4 @@ const CurrentImageFallback = (props: CurrentImageFallbackProps) => {
);
};
export default CurrentImageFallback;
export default ImageFallbackSpinner;

View File

@ -5,6 +5,7 @@ import {
FlexProps,
Grid,
Icon,
Image,
Text,
forwardRef,
} from '@chakra-ui/react';
@ -14,7 +15,10 @@ import IAICheckbox from 'common/components/IAICheckbox';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISlider from 'common/components/IAISlider';
import { imageGallerySelector } from 'features/gallery/store/gallerySelectors';
import {
gallerySelector,
imageGallerySelector,
} from 'features/gallery/store/gallerySelectors';
import {
setCurrentCategory,
setGalleryImageMinimumWidth,
@ -50,30 +54,42 @@ import { uploadsAdapter } from '../store/uploadsSlice';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { Virtuoso, VirtuosoGrid } from 'react-virtuoso';
import { Image as ImageType } from 'app/types/invokeai';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import GalleryProgressImage from './GalleryProgressImage';
const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 290;
const PROGRESS_IMAGE_PLACEHOLDER = 'PROGRESS_IMAGE_PLACEHOLDER';
const gallerySelector = createSelector(
[
(state: RootState) => state.uploads,
(state: RootState) => state.results,
(state: RootState) => state.gallery,
],
(uploads, results, gallery) => {
const selector = createSelector(
[(state: RootState) => state],
(state) => {
const { results, uploads, system, gallery } = state;
const { currentCategory } = gallery;
return currentCategory === 'results'
? {
images: resultsAdapter.getSelectors().selectAll(results),
isLoading: results.isLoading,
areMoreImagesAvailable: results.page < results.pages - 1,
}
: {
images: uploadsAdapter.getSelectors().selectAll(uploads),
isLoading: uploads.isLoading,
areMoreImagesAvailable: uploads.page < uploads.pages - 1,
};
}
if (currentCategory === 'results') {
const tempImages: (ImageType | typeof PROGRESS_IMAGE_PLACEHOLDER)[] = [];
if (system.progressImage) {
tempImages.push(PROGRESS_IMAGE_PLACEHOLDER);
}
return {
images: tempImages.concat(
resultsAdapter.getSelectors().selectAll(results)
),
isLoading: results.isLoading,
areMoreImagesAvailable: results.page < results.pages - 1,
};
}
return {
images: uploadsAdapter.getSelectors().selectAll(uploads),
isLoading: uploads.isLoading,
areMoreImagesAvailable: uploads.page < uploads.pages - 1,
};
},
defaultSelectorOptions
);
const ImageGalleryContent = () => {
@ -108,7 +124,7 @@ const ImageGalleryContent = () => {
} = useAppSelector(imageGallerySelector);
const { images, areMoreImagesAvailable, isLoading } =
useAppSelector(gallerySelector);
useAppSelector(selector);
const handleClickLoadMore = () => {
if (currentCategory === 'results') {
@ -170,8 +186,24 @@ const ImageGalleryContent = () => {
}
}, []);
const handleEndReached = useCallback(() => {
if (currentCategory === 'results') {
dispatch(receivedResultImagesPage());
} else if (currentCategory === 'uploads') {
dispatch(receivedUploadImagesPage());
}
}, [dispatch, currentCategory]);
return (
<Flex flexDirection="column" w="full" h="full" gap={4}>
<Flex
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
borderRadius: 'base',
}}
>
<Flex
ref={resizeObserverRef}
alignItems="center"
@ -290,18 +322,27 @@ const ImageGalleryContent = () => {
<Virtuoso
style={{ height: '100%' }}
data={images}
endReached={handleEndReached}
scrollerRef={(ref) => setScrollerRef(ref)}
itemContent={(index, image) => {
const { name } = image;
const isSelected = selectedImage?.name === name;
const isSelected =
image === PROGRESS_IMAGE_PLACEHOLDER
? false
: selectedImage?.name === image?.name;
return (
<Flex sx={{ pb: 2 }}>
<HoverableImage
key={`${name}-${image.thumbnail}`}
image={image}
isSelected={isSelected}
/>
{image === PROGRESS_IMAGE_PLACEHOLDER ? (
<GalleryProgressImage
key={PROGRESS_IMAGE_PLACEHOLDER}
/>
) : (
<HoverableImage
key={`${image.name}-${image.thumbnail}`}
image={image}
isSelected={isSelected}
/>
)}
</Flex>
);
}}
@ -310,18 +351,23 @@ const ImageGalleryContent = () => {
<VirtuosoGrid
style={{ height: '100%' }}
data={images}
endReached={handleEndReached}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, image) => {
const { name } = image;
const isSelected = selectedImage?.name === name;
const isSelected =
image === PROGRESS_IMAGE_PLACEHOLDER
? false
: selectedImage?.name === image?.name;
return (
return image === PROGRESS_IMAGE_PLACEHOLDER ? (
<GalleryProgressImage key={PROGRESS_IMAGE_PLACEHOLDER} />
) : (
<HoverableImage
key={`${name}-${image.thumbnail}`}
key={`${image.name}-${image.thumbnail}`}
image={image}
isSelected={isSelected}
/>
@ -334,6 +380,7 @@ const ImageGalleryContent = () => {
onClick={handleClickLoadMore}
isDisabled={!areMoreImagesAvailable}
isLoading={isLoading}
loadingText="Loading"
flexShrink={0}
>
{areMoreImagesAvailable

View File

@ -5,7 +5,6 @@ import {
// selectPrevImage,
setGalleryImageMinimumWidth,
} from 'features/gallery/store/gallerySlice';
import { InvokeTabName } from 'features/ui/store/tabMap';
import { clamp, isEqual } from 'lodash-es';
import { useHotkeys } from 'react-hotkeys-hook';
@ -13,11 +12,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import './ImageGallery.css';
import ImageGalleryContent from './ImageGalleryContent';
import ResizableDrawer from 'features/ui/components/common/ResizableDrawer/ResizableDrawer';
import {
setShouldShowGallery,
toggleGalleryPanel,
togglePinGalleryPanel,
} from 'features/ui/store/uiSlice';
import { setShouldShowGallery } from 'features/ui/store/uiSlice';
import { createSelector } from '@reduxjs/toolkit';
import {
activeTabNameSelector,
@ -26,22 +21,20 @@ import {
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import useResolution from 'common/hooks/useResolution';
import { Flex } from '@chakra-ui/react';
import { memo } from 'react';
const GALLERY_TAB_WIDTHS: Record<
InvokeTabName,
{ galleryMinWidth: number; galleryMaxWidth: number }
> = {
// txt2img: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// img2img: { galleryMinWidth: 200, galleryMaxWidth: 500 },
generate: { galleryMinWidth: 200, galleryMaxWidth: 500 },
unifiedCanvas: { galleryMinWidth: 200, galleryMaxWidth: 200 },
nodes: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// postprocessing: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// training: { galleryMinWidth: 200, galleryMaxWidth: 500 },
};
// const GALLERY_TAB_WIDTHS: Record<
// InvokeTabName,
// { galleryMinWidth: number; galleryMaxWidth: number }
// > = {
// txt2img: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// img2img: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// generate: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// unifiedCanvas: { galleryMinWidth: 200, galleryMaxWidth: 200 },
// nodes: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// postprocessing: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// training: { galleryMinWidth: 200, galleryMaxWidth: 500 },
// };
const galleryPanelSelector = createSelector(
[
@ -73,50 +66,50 @@ const galleryPanelSelector = createSelector(
}
);
export const ImageGalleryPanel = () => {
const GalleryDrawer = () => {
const dispatch = useAppDispatch();
const {
shouldPinGallery,
shouldShowGallery,
galleryImageMinimumWidth,
activeTabName,
isStaging,
isResizable,
isLightboxOpen,
// activeTabName,
// isStaging,
// isResizable,
// isLightboxOpen,
} = useAppSelector(galleryPanelSelector);
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
// const handleSetShouldPinGallery = () => {
// dispatch(togglePinGalleryPanel());
// dispatch(requestCanvasRescale());
// };
const handleToggleGallery = () => {
dispatch(toggleGalleryPanel());
shouldPinGallery && dispatch(requestCanvasRescale());
};
// const handleToggleGallery = () => {
// dispatch(toggleGalleryPanel());
// shouldPinGallery && dispatch(requestCanvasRescale());
// };
const handleCloseGallery = () => {
dispatch(setShouldShowGallery(false));
shouldPinGallery && dispatch(requestCanvasRescale());
};
const resolution = useResolution();
// const resolution = useResolution();
useHotkeys(
'g',
() => {
handleToggleGallery();
},
[shouldPinGallery]
);
// useHotkeys(
// 'g',
// () => {
// handleToggleGallery();
// },
// [shouldPinGallery]
// );
useHotkeys(
'shift+g',
() => {
handleSetShouldPinGallery();
},
[shouldPinGallery]
);
// useHotkeys(
// 'shift+g',
// () => {
// handleSetShouldPinGallery();
// },
// [shouldPinGallery]
// );
useHotkeys(
'esc',
@ -162,55 +155,71 @@ export const ImageGalleryPanel = () => {
[galleryImageMinimumWidth]
);
const calcGalleryMinHeight = () => {
if (resolution === 'desktop') return;
return 300;
};
// const calcGalleryMinHeight = () => {
// if (resolution === 'desktop') return;
// return 300;
// };
const imageGalleryContent = () => {
return (
<Flex
w="100vw"
h={{ base: 300, xl: '100vh' }}
paddingRight={{ base: 8, xl: 0 }}
paddingBottom={{ base: 4, xl: 0 }}
>
<ImageGalleryContent />
</Flex>
);
};
// const imageGalleryContent = () => {
// return (
// <Flex
// w="100vw"
// h={{ base: 300, xl: '100vh' }}
// paddingRight={{ base: 8, xl: 0 }}
// paddingBottom={{ base: 4, xl: 0 }}
// >
// <ImageGalleryContent />
// </Flex>
// );
// };
const resizableImageGalleryContent = () => {
return (
<ResizableDrawer
direction="right"
isResizable={isResizable || !shouldPinGallery}
isOpen={shouldShowGallery}
onClose={handleCloseGallery}
isPinned={shouldPinGallery && !isLightboxOpen}
minWidth={
shouldPinGallery
? GALLERY_TAB_WIDTHS[activeTabName].galleryMinWidth
: 200
}
maxWidth={
shouldPinGallery
? GALLERY_TAB_WIDTHS[activeTabName].galleryMaxWidth
: undefined
}
minHeight={calcGalleryMinHeight()}
>
<ImageGalleryContent />
</ResizableDrawer>
);
};
// const resizableImageGalleryContent = () => {
// return (
// <ResizableDrawer
// direction="right"
// isResizable={isResizable || !shouldPinGallery}
// isOpen={shouldShowGallery}
// onClose={handleCloseGallery}
// isPinned={shouldPinGallery && !isLightboxOpen}
// minWidth={
// shouldPinGallery
// ? GALLERY_TAB_WIDTHS[activeTabName].galleryMinWidth
// : 200
// }
// maxWidth={
// shouldPinGallery
// ? GALLERY_TAB_WIDTHS[activeTabName].galleryMaxWidth
// : undefined
// }
// minHeight={calcGalleryMinHeight()}
// >
// <ImageGalleryContent />
// </ResizableDrawer>
// );
// };
const renderImageGallery = () => {
if (['mobile', 'tablet'].includes(resolution)) return imageGalleryContent();
return resizableImageGalleryContent();
};
// const renderImageGallery = () => {
// if (['mobile', 'tablet'].includes(resolution)) return imageGalleryContent();
// return resizableImageGalleryContent();
// };
return renderImageGallery();
if (shouldPinGallery) {
return null;
}
return (
<ResizableDrawer
direction="right"
isResizable={true}
isOpen={shouldShowGallery}
onClose={handleCloseGallery}
minWidth={200}
>
<ImageGalleryContent />
</ResizableDrawer>
);
// return renderImageGallery();
};
export default memo(ImageGalleryPanel);
export default memo(GalleryDrawer);

View File

@ -3,7 +3,6 @@ import {
Box,
Center,
Flex,
Heading,
IconButton,
Link,
Text,
@ -19,8 +18,6 @@ import {
setCfgScale,
setHeight,
setImg2imgStrength,
// setInitialImage,
setMaskPath,
setPerlin,
setSampler,
setSeamless,
@ -31,21 +28,14 @@ import {
setThreshold,
setWidth,
} from 'features/parameters/store/generationSlice';
import {
setCodeformerFidelity,
setFacetoolStrength,
setFacetoolType,
setHiresFix,
setUpscalingDenoising,
setUpscalingLevel,
setUpscalingStrength,
} from 'features/parameters/store/postprocessingSlice';
import { setHiresFix } from 'features/parameters/store/postprocessingSlice';
import { setShouldShowImageDetails } from 'features/ui/store/uiSlice';
import { memo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaCopy } from 'react-icons/fa';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
type MetadataItemProps = {
isLink?: boolean;
@ -300,7 +290,7 @@ const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => {
</Text>
</Center>
)}
<Flex gap={2} direction="column">
<Flex gap={2} direction="column" overflow="auto">
<Flex gap={2}>
<Tooltip label="Copy metadata JSON">
<IconButton
@ -314,22 +304,19 @@ const ImageMetadataViewer = memo(({ image }: ImageMetadataViewerProps) => {
</Tooltip>
<Text fontWeight="semibold">Metadata JSON:</Text>
</Flex>
<Box
sx={{
mt: 0,
mr: 2,
mb: 4,
ml: 2,
padding: 4,
borderRadius: 'base',
overflowX: 'scroll',
wordBreak: 'break-all',
bg: 'whiteAlpha.500',
_dark: { bg: 'blackAlpha.500' },
}}
>
<pre>{metadataJSON}</pre>
</Box>
<OverlayScrollbarsComponent defer>
<Box
sx={{
padding: 4,
borderRadius: 'base',
bg: 'whiteAlpha.500',
_dark: { bg: 'blackAlpha.500' },
w: 'max-content',
}}
>
<pre>{metadataJSON}</pre>
</Box>
</OverlayScrollbarsComponent>
</Flex>
</Flex>
);

View File

@ -0,0 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import { Image } from 'app/types/invokeai';
import { SelectedImage } from 'features/parameters/store/actions';
export const requestedImageDeletion = createAction<
Image | SelectedImage | undefined
>('gallery/requestedImageDeletion');

View File

@ -4,12 +4,13 @@ import { GalleryState } from './gallerySlice';
* Gallery slice persist denylist
*/
const itemsToDenylist: (keyof GalleryState)[] = [
'categories',
'currentCategory',
'currentImage',
'currentImageUuid',
'shouldAutoSwitchToNewImages',
'intermediateImage',
];
export const galleryPersistDenylist: (keyof GalleryState)[] = [
'currentCategory',
'shouldAutoSwitchToNewImages',
];
export const galleryDenylist = itemsToDenylist.map(

View File

@ -1,23 +1,14 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import { configSelector } from 'features/system/store/configSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import {
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { isEqual } from 'lodash-es';
import {
selectResultsAll,
selectResultsById,
selectResultsEntities,
} from './resultsSlice';
import {
selectUploadsAll,
selectUploadsById,
selectUploadsEntities,
} from './uploadsSlice';
import { selectResultsById, selectResultsEntities } from './resultsSlice';
import { selectUploadsAll, selectUploadsById } from './uploadsSlice';
export const gallerySelector = (state: RootState) => state.gallery;
@ -44,6 +35,11 @@ export const imageGallerySelector = createSelector(
const { isLightboxOpen } = lightbox;
const images =
currentCategory === 'results'
? selectResultsEntities(state)
: selectUploadsAll(state);
return {
shouldPinGallery,
galleryImageMinimumWidth,
@ -53,7 +49,7 @@ export const imageGallerySelector = createSelector(
: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, auto))`,
shouldAutoSwitchToNewImages,
currentCategory,
images: state[currentCategory].entities,
images,
galleryWidth,
shouldEnableResize:
isLightboxOpen ||

View File

@ -1,10 +1,11 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { invocationComplete } from 'services/events/actions';
import { isImageOutput } from 'services/types/guards';
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import { imageUploaded } from 'services/thunks/image';
import { SelectedImage } from 'features/parameters/store/generationSlice';
import { Image } from 'app/types/invokeai';
import { imageReceived, thumbnailReceived } from 'services/thunks/image';
import {
receivedResultImagesPage,
receivedUploadImagesPage,
} from '../../../services/thunks/gallery';
type GalleryImageObjectFitType = 'contain' | 'cover';
@ -12,7 +13,7 @@ export interface GalleryState {
/**
* The selected image
*/
selectedImage?: SelectedImage;
selectedImage?: Image;
galleryImageMinimumWidth: number;
galleryImageObjectFit: GalleryImageObjectFitType;
shouldAutoSwitchToNewImages: boolean;
@ -21,8 +22,7 @@ export interface GalleryState {
currentCategory: 'results' | 'uploads';
}
const initialState: GalleryState = {
selectedImage: undefined,
export const initialGalleryState: GalleryState = {
galleryImageMinimumWidth: 64,
galleryImageObjectFit: 'cover',
shouldAutoSwitchToNewImages: true,
@ -33,12 +33,9 @@ const initialState: GalleryState = {
export const gallerySlice = createSlice({
name: 'gallery',
initialState,
initialState: initialGalleryState,
reducers: {
imageSelected: (
state,
action: PayloadAction<SelectedImage | undefined>
) => {
imageSelected: (state, action: PayloadAction<Image | undefined>) => {
state.selectedImage = action.payload;
// TODO: if the user selects an image, disable the auto switch?
// state.shouldAutoSwitchToNewImages = false;
@ -72,27 +69,50 @@ export const gallerySlice = createSlice({
},
},
extraReducers(builder) {
/**
* Invocation Complete
*/
builder.addCase(invocationComplete, (state, action) => {
const { data } = action.payload;
if (isImageOutput(data.result) && state.shouldAutoSwitchToNewImages) {
state.selectedImage = {
name: data.result.image.image_name,
type: 'results',
};
builder.addCase(imageReceived.fulfilled, (state, action) => {
// When we get an updated URL for an image, we need to update the selectedImage in gallery,
// which is currently its own object (instead of a reference to an image in results/uploads)
const { imagePath } = action.payload;
const { imageName } = action.meta.arg;
if (state.selectedImage?.name === imageName) {
state.selectedImage.url = imagePath;
}
});
/**
* Upload Image - FULFILLED
*/
builder.addCase(imageUploaded.fulfilled, (state, action) => {
const { response } = action.payload;
builder.addCase(thumbnailReceived.fulfilled, (state, action) => {
// When we get an updated URL for an image, we need to update the selectedImage in gallery,
// which is currently its own object (instead of a reference to an image in results/uploads)
const { thumbnailPath } = action.payload;
const { thumbnailName } = action.meta.arg;
const uploadedImage = deserializeImageResponse(response);
state.selectedImage = { name: uploadedImage.name, type: 'uploads' };
if (state.selectedImage?.name === thumbnailName) {
state.selectedImage.thumbnail = thumbnailPath;
}
});
builder.addCase(receivedResultImagesPage.fulfilled, (state, action) => {
// rehydrate selectedImage URL when results list comes in
// solves case when outdated URL is in local storage
if (state.selectedImage) {
const selectedImageInResults = action.payload.items.find(
(image) => image.image_name === state.selectedImage!.name
);
if (selectedImageInResults) {
state.selectedImage.url = selectedImageInResults.image_url;
}
}
});
builder.addCase(receivedUploadImagesPage.fulfilled, (state, action) => {
// rehydrate selectedImage URL when results list comes in
// solves case when outdated URL is in local storage
if (state.selectedImage) {
const selectedImageInResults = action.payload.items.find(
(image) => image.image_name === state.selectedImage!.name
);
if (selectedImageInResults) {
state.selectedImage.url = selectedImageInResults.image_url;
}
}
});
},
});

View File

@ -5,7 +5,9 @@ import { ResultsState } from './resultsSlice';
*
* Currently denylisting results slice entirely, see persist config in store.ts
*/
const itemsToDenylist: (keyof ResultsState)[] = ['isLoading'];
const itemsToDenylist: (keyof ResultsState)[] = [];
export const resultsPersistDenylist: (keyof ResultsState)[] = [];
export const resultsDenylist = itemsToDenylist.map(
(denylistItem) => `results.${denylistItem}`

View File

@ -1,17 +1,11 @@
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { Image } from 'app/types/invokeai';
import { invocationComplete } from 'services/events/actions';
import { RootState } from 'app/store/store';
import {
receivedResultImagesPage,
IMAGES_PER_PAGE,
} from 'services/thunks/gallery';
import { isImageOutput } from 'services/types/guards';
import {
buildImageUrls,
extractTimestampFromImageName,
} from 'services/util/deserializeImageField';
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
import {
imageDeleted,
@ -73,44 +67,6 @@ const resultsSlice = createSlice({
state.isLoading = false;
});
/**
* Invocation Complete
*/
builder.addCase(invocationComplete, (state, action) => {
const { data, shouldFetchImages } = action.payload;
const { result, node, graph_execution_state_id } = data;
if (isImageOutput(result)) {
const name = result.image.image_name;
const type = result.image.image_type;
// if we need to refetch, set URLs to placeholder for now
const { url, thumbnail } = shouldFetchImages
? { url: '', thumbnail: '' }
: buildImageUrls(type, name);
const timestamp = extractTimestampFromImageName(name);
const image: Image = {
name,
type,
url,
thumbnail,
metadata: {
created: timestamp,
width: result.width,
height: result.height,
invokeai: {
session_id: graph_execution_state_id,
...(node ? { node } : {}),
},
},
};
resultsAdapter.setOne(state, image);
}
});
/**
* Image Received - FULFILLED
*/
@ -142,9 +98,10 @@ const resultsSlice = createSlice({
});
/**
* Delete Image - FULFILLED
* Delete Image - PENDING
* Pre-emptively remove the image from the gallery
*/
builder.addCase(imageDeleted.fulfilled, (state, action) => {
builder.addCase(imageDeleted.pending, (state, action) => {
const { imageType, imageName } = action.meta.arg;
if (imageType === 'results') {

View File

@ -5,7 +5,8 @@ import { UploadsState } from './uploadsSlice';
*
* Currently denylisting uploads slice entirely, see persist config in store.ts
*/
const itemsToDenylist: (keyof UploadsState)[] = ['isLoading'];
const itemsToDenylist: (keyof UploadsState)[] = [];
export const uploadsPersistDenylist: (keyof UploadsState)[] = [];
export const uploadsDenylist = itemsToDenylist.map(
(denylistItem) => `uploads.${denylistItem}`

View File

@ -6,7 +6,7 @@ import {
receivedUploadImagesPage,
IMAGES_PER_PAGE,
} from 'services/thunks/gallery';
import { imageDeleted, imageUploaded } from 'services/thunks/image';
import { imageDeleted } from 'services/thunks/image';
import { deserializeImageResponse } from 'services/util/deserializeImageResponse';
export const uploadsAdapter = createEntityAdapter<Image>({
@ -21,7 +21,7 @@ type AdditionalUploadsState = {
nextPage: number;
};
const initialUploadsState =
export const initialUploadsState =
uploadsAdapter.getInitialState<AdditionalUploadsState>({
page: 0,
pages: 0,
@ -35,7 +35,7 @@ const uploadsSlice = createSlice({
name: 'uploads',
initialState: initialUploadsState,
reducers: {
uploadAdded: uploadsAdapter.addOne,
uploadAdded: uploadsAdapter.upsertOne,
},
extraReducers: (builder) => {
/**
@ -62,20 +62,10 @@ const uploadsSlice = createSlice({
});
/**
* Upload Image - FULFILLED
* Delete Image - pending
* Pre-emptively remove the image from the gallery
*/
builder.addCase(imageUploaded.fulfilled, (state, action) => {
const { location, response } = action.payload;
const uploadedImage = deserializeImageResponse(response);
uploadsAdapter.setOne(state, uploadedImage);
});
/**
* Delete Image - FULFILLED
*/
builder.addCase(imageDeleted.fulfilled, (state, action) => {
builder.addCase(imageDeleted.pending, (state, action) => {
const { imageType, imageName } = action.meta.arg;
if (imageType === 'uploads') {

View File

@ -4,7 +4,7 @@ import * as InvokeAI from 'app/types/invokeai';
import { useGetUrl } from 'common/util/getUrl';
type ReactPanZoomProps = {
image: InvokeAI._Image;
image: InvokeAI.Image;
styleClass?: string;
alt?: string;
ref?: React.Ref<HTMLImageElement>;

View File

@ -4,6 +4,9 @@ import { LightboxState } from './lightboxSlice';
* Lightbox slice persist denylist
*/
const itemsToDenylist: (keyof LightboxState)[] = ['isLightboxOpen'];
export const lightboxPersistDenylist: (keyof LightboxState)[] = [
'isLightboxOpen',
];
export const lightboxDenylist = itemsToDenylist.map(
(denylistItem) => `lightbox.${denylistItem}`

View File

@ -5,7 +5,7 @@ export interface LightboxState {
isLightboxOpen: boolean;
}
const initialLightboxState: LightboxState = {
export const initialLightboxState: LightboxState = {
isLightboxOpen: false,
};

View File

@ -1,5 +1,3 @@
import { v4 as uuidv4 } from 'uuid';
import 'reactflow/dist/style.css';
import { memo, useCallback } from 'react';
import {
@ -8,12 +6,11 @@ import {
MenuButton,
MenuList,
MenuItem,
IconButton,
} from '@chakra-ui/react';
import { FaEllipsisV, FaPlus } from 'react-icons/fa';
import { FaEllipsisV } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { nodeAdded } from '../store/nodesSlice';
import { cloneDeep, map } from 'lodash-es';
import { map } from 'lodash-es';
import { RootState } from 'app/store/store';
import { useBuildInvocation } from '../hooks/useBuildInvocation';
import { addToast } from 'features/system/store/systemSlice';

View File

@ -1,12 +1,6 @@
import { Tooltip } from '@chakra-ui/react';
import { CSSProperties, memo, useMemo } from 'react';
import {
Handle,
Position,
Connection,
HandleType,
useReactFlow,
} from 'reactflow';
import { CSSProperties, memo } from 'react';
import { Handle, Position, Connection, HandleType } from 'reactflow';
import { FIELDS, HANDLE_TOOLTIP_OPEN_DELAY } from '../types/constants';
// import { useConnectionEventStyles } from '../hooks/useConnectionEventStyles';
import { InputFieldTemplate, OutputFieldTemplate } from '../types/types';
@ -26,9 +20,9 @@ const outputHandleStyles: CSSProperties = {
right: '-0.5rem',
};
const requiredConnectionStyles: CSSProperties = {
boxShadow: '0 0 0.5rem 0.5rem var(--invokeai-colors-error-400)',
};
// const requiredConnectionStyles: CSSProperties = {
// boxShadow: '0 0 0.5rem 0.5rem var(--invokeai-colors-error-400)',
// };
type FieldHandleProps = {
nodeId: string;
@ -39,8 +33,8 @@ type FieldHandleProps = {
};
const FieldHandle = (props: FieldHandleProps) => {
const { nodeId, field, isValidConnection, handleType, styles } = props;
const { name, title, type, description } = field;
const { field, isValidConnection, handleType, styles } = props;
const { name, type } = field;
return (
<Tooltip

View File

@ -23,7 +23,6 @@ import TopRightPanel from './panels/TopRightPanel';
import TopCenterPanel from './panels/TopCenterPanel';
import BottomLeftPanel from './panels/BottomLeftPanel.tsx';
import MinimapPanel from './panels/MinimapPanel';
import NodeSearch from './search/NodeSearch';
const nodeTypes = { invocation: InvocationComponent };
@ -78,8 +77,7 @@ export const Flow = () => {
style: { strokeWidth: 2 },
}}
>
<NodeSearch />
{/* <TopLeftPanel /> */}
<TopLeftPanel />
<TopCenterPanel />
<TopRightPanel />
<BottomLeftPanel />

View File

@ -1,6 +1,6 @@
import { Flex, Heading, Tooltip, Icon } from '@chakra-ui/react';
import { InvocationTemplate } from 'features/nodes/types/types';
import { memo, MutableRefObject } from 'react';
import { memo } from 'react';
import { FaInfoCircle } from 'react-icons/fa';
interface IAINodeHeaderProps {

View File

@ -10,6 +10,7 @@ import ConditioningInputFieldComponent from './fields/ConditioningInputFieldComp
import ModelInputFieldComponent from './fields/ModelInputFieldComponent';
import NumberInputFieldComponent from './fields/NumberInputFieldComponent';
import StringInputFieldComponent from './fields/StringInputFieldComponent';
import ColorInputFieldComponent from './fields/ColorInputFieldComponent';
import ItemInputFieldComponent from './fields/ItemInputFieldComponent';
type InputFieldComponentProps = {
@ -21,7 +22,7 @@ type InputFieldComponentProps = {
// build an individual input element based on the schema
const InputFieldComponent = (props: InputFieldComponentProps) => {
const { nodeId, field, template } = props;
const { type, value } = field;
const { type } = field;
if (type === 'string' && template.type === 'string') {
return (
@ -126,6 +127,26 @@ const InputFieldComponent = (props: InputFieldComponentProps) => {
);
}
if (type === 'color' && template.type === 'color') {
return (
<ColorInputFieldComponent
nodeId={nodeId}
field={field}
template={template}
/>
);
}
if (type === 'item' && template.type === 'item') {
return (
<ItemInputFieldComponent
nodeId={nodeId}
field={field}
template={template}
/>
);
}
return <Box p={2}>Unknown field type: {type}</Box>;
};

View File

@ -2,7 +2,7 @@ import { Box } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { memo } from 'react';
import { buildNodesGraph } from '../util/nodesGraphBuilder/buildNodesGraph';
import { buildNodesGraph } from '../util/graphBuilders/buildNodesGraph';
const NodeGraphOverlay = () => {
const state = useAppSelector((state: RootState) => state);

View File

@ -0,0 +1,31 @@
import {
ColorInputFieldTemplate,
ColorInputFieldValue,
} from 'features/nodes/types/types';
import { memo } from 'react';
import { FieldComponentProps } from './types';
import { RgbaColor, RgbaColorPicker } from 'react-colorful';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { useAppDispatch } from 'app/store/storeHooks';
const ColorInputFieldComponent = (
props: FieldComponentProps<ColorInputFieldValue, ColorInputFieldTemplate>
) => {
const { nodeId, field } = props;
const dispatch = useAppDispatch();
const handleValueChanged = (value: RgbaColor) => {
dispatch(fieldValueChanged({ nodeId, fieldName: field.name, value }));
};
return (
<RgbaColorPicker
className="nodrag"
color={field.value}
onChange={handleValueChanged}
/>
);
};
export default memo(ColorInputFieldComponent);

View File

@ -1,16 +1,16 @@
import { Box, Image, Icon, Flex } from '@chakra-ui/react';
import { Box, Image } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import SelectImagePlaceholder from 'common/components/SelectImagePlaceholder';
import { useGetUrl } from 'common/util/getUrl';
import useGetImageByNameAndType from 'features/gallery/hooks/useGetImageByName';
import useGetImageByUuid from 'features/gallery/hooks/useGetImageByUuid';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import {
ImageInputFieldTemplate,
ImageInputFieldValue,
} from 'features/nodes/types/types';
import { DragEvent, memo, useCallback, useState } from 'react';
import { FaImage } from 'react-icons/fa';
import { ImageType } from 'services/api';
import { FieldComponentProps } from './types';
@ -18,7 +18,6 @@ const ImageInputFieldComponent = (
props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate>
) => {
const { nodeId, field } = props;
const { value } = field;
const getImageByNameAndType = useGetImageByNameAndType();
const dispatch = useAppDispatch();

View File

@ -3,7 +3,7 @@ import {
ItemInputFieldValue,
} from 'features/nodes/types/types';
import { memo } from 'react';
import { FaAddressCard, FaList } from 'react-icons/fa';
import { FaAddressCard } from 'react-icons/fa';
import { FieldComponentProps } from './types';
const ItemInputFieldComponent = (

View File

@ -1,17 +1,13 @@
import { Select } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import {
ModelInputFieldTemplate,
ModelInputFieldValue,
} from 'features/nodes/types/types';
import {
selectModelsById,
selectModelsIds,
} from 'features/system/store/modelSlice';
import { isEqual, map } from 'lodash-es';
import { selectModelsIds } from 'features/system/store/modelSlice';
import { isEqual } from 'lodash-es';
import { ChangeEvent, memo } from 'react';
import { FieldComponentProps } from './types';
@ -48,7 +44,10 @@ const ModelInputFieldComponent = (
};
return (
<Select onChange={handleValueChanged} value={field.value}>
<Select
onChange={handleValueChanged}
value={field.value || allModelNames[0]}
>
{allModelNames.map((option) => (
<option key={option}>{option}</option>
))}

View File

@ -1,16 +1,16 @@
import { HStack } from '@chakra-ui/react';
import { userInvoked } from 'app/store/actions';
import { useAppDispatch } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import { memo, useCallback } from 'react';
import { Panel } from 'reactflow';
import { receivedOpenAPISchema } from 'services/thunks/schema';
import { nodesGraphBuilt } from 'services/thunks/session';
const TopCenterPanel = () => {
const dispatch = useAppDispatch();
const handleInvoke = useCallback(() => {
dispatch(nodesGraphBuilt());
dispatch(userInvoked('nodes'));
}, [dispatch]);
const handleReloadSchema = useCallback(() => {

View File

@ -1,10 +1,10 @@
import { memo } from 'react';
import { Panel } from 'reactflow';
import AddNodeMenu from '../AddNodeMenu';
import NodeSearch from '../search/NodeSearch';
const TopLeftPanel = () => (
<Panel position="top-left">
<AddNodeMenu />
<NodeSearch />
</Panel>
);

View File

@ -2,7 +2,6 @@ import { Box, Flex } from '@chakra-ui/layout';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIInput from 'common/components/IAIInput';
import { Panel } from 'reactflow';
import { map } from 'lodash-es';
import {
ChangeEvent,
@ -192,19 +191,17 @@ const NodeSearch = () => {
};
return (
<Panel position="top-left">
<Flex
flexDirection="column"
tabIndex={1}
onKeyDown={searchKeyHandler}
onFocus={() => setShowNodeList(true)}
onBlur={searchInputBlurHandler}
ref={nodeSearchRef}
>
<IAIInput value={searchText} onChange={findNode} />
{showNodeList && renderNodeList()}
</Flex>
</Panel>
<Flex
flexDirection="column"
tabIndex={1}
onKeyDown={searchKeyHandler}
onFocus={() => setShowNodeList(true)}
onBlur={searchInputBlurHandler}
ref={nodeSearchRef}
>
<IAIInput value={searchText} onChange={findNode} />
{showNodeList && renderNodeList()}
</Flex>
);
};

View File

@ -20,7 +20,7 @@ export const iterationGraph = {
model: '',
progress_images: false,
prompt: 'dog',
sampler_name: 'k_lms',
sampler_name: 'lms',
seamless: false,
steps: 11,
type: 'txt2img',

View File

@ -0,0 +1,18 @@
import { createAction, isAnyOf } from '@reduxjs/toolkit';
import { Graph } from 'services/api';
export const textToImageGraphBuilt = createAction<Graph>(
'nodes/textToImageGraphBuilt'
);
export const imageToImageGraphBuilt = createAction<Graph>(
'nodes/imageToImageGraphBuilt'
);
export const canvasGraphBuilt = createAction<Graph>('nodes/canvasGraphBuilt');
export const nodesGraphBuilt = createAction<Graph>('nodes/nodesGraphBuilt');
export const isAnyGraphBuilt = isAnyOf(
textToImageGraphBuilt,
imageToImageGraphBuilt,
canvasGraphBuilt,
nodesGraphBuilt
);

View File

@ -4,6 +4,10 @@ import { NodesState } from './nodesSlice';
* Nodes slice persist denylist
*/
const itemsToDenylist: (keyof NodesState)[] = ['schema', 'invocationTemplates'];
export const nodesPersistDenylist: (keyof NodesState)[] = [
'schema',
'invocationTemplates',
];
export const nodesDenylist = itemsToDenylist.map(
(denylistItem) => `nodes.${denylistItem}`

View File

@ -11,13 +11,14 @@ import {
NodeChange,
OnConnectStartParams,
} from 'reactflow';
import { Graph, ImageField } from 'services/api';
import { ImageField } from 'services/api';
import { receivedOpenAPISchema } from 'services/thunks/schema';
import { isFulfilledAnyGraphBuilt } from 'services/thunks/session';
import { InvocationTemplate, InvocationValue } from '../types/types';
import { parseSchema } from '../util/parseSchema';
import { log } from 'app/logging/useLogger';
import { size } from 'lodash-es';
import { isAnyGraphBuilt } from './actions';
import { RgbaColor } from 'react-colorful';
export type NodesState = {
nodes: Node<InvocationValue>[];
@ -25,7 +26,6 @@ export type NodesState = {
schema: OpenAPIV3.Document | null;
invocationTemplates: Record<string, InvocationTemplate>;
connectionStartParams: OnConnectStartParams | null;
lastGraph: Graph | null;
shouldShowGraphOverlay: boolean;
};
@ -35,7 +35,6 @@ export const initialNodesState: NodesState = {
schema: null,
invocationTemplates: {},
connectionStartParams: null,
lastGraph: null,
shouldShowGraphOverlay: false,
};
@ -71,6 +70,7 @@ const nodesSlice = createSlice({
| number
| boolean
| Pick<ImageField, 'image_name' | 'image_type'>
| RgbaColor
| undefined;
}>
) => {
@ -104,8 +104,9 @@ const nodesSlice = createSlice({
state.schema = action.payload;
});
builder.addMatcher(isFulfilledAnyGraphBuilt, (state, action) => {
state.lastGraph = action.payload;
builder.addMatcher(isAnyGraphBuilt, (state, action) => {
// TODO: Achtung! Side effect in a reducer!
log.info({ namespace: 'nodes', data: action.payload }, 'Graph built');
});
},
});

View File

@ -1,4 +1,3 @@
import { getCSSVar } from '@chakra-ui/utils';
import { FieldType, FieldUIConfig } from './types';
export const HANDLE_TOOLTIP_OPEN_DELAY = 500;
@ -15,6 +14,7 @@ export const FIELD_TYPE_MAP: Record<string, FieldType> = {
model: 'model',
array: 'array',
item: 'item',
ColorField: 'color',
};
const COLOR_TOKEN_VALUE = 500;
@ -89,4 +89,10 @@ export const FIELDS: Record<FieldType, FieldUIConfig> = {
title: 'Collection Item',
description: 'TODO: Collection Item type description.',
},
color: {
color: 'gray',
colorCssVar: getColorTokenCssVariable('gray'),
title: 'Color',
description: 'A RGBA color.',
},
};

View File

@ -1,4 +1,5 @@
import { OpenAPIV3 } from 'openapi-types';
import { RgbaColor } from 'react-colorful';
import { ImageField } from 'services/api';
import { AnyInvocationType } from 'services/events/types';
@ -59,7 +60,8 @@ export type FieldType =
| 'conditioning'
| 'model'
| 'array'
| 'item';
| 'item'
| 'color';
/**
* An input field is persisted across reloads as part of the user's local state.
@ -80,7 +82,8 @@ export type InputFieldValue =
| EnumInputFieldValue
| ModelInputFieldValue
| ArrayInputFieldValue
| ItemInputFieldValue;
| ItemInputFieldValue
| ColorInputFieldValue;
/**
* An input field template is generated on each page load from the OpenAPI schema.
@ -99,7 +102,8 @@ export type InputFieldTemplate =
| EnumInputFieldTemplate
| ModelInputFieldTemplate
| ArrayInputFieldTemplate
| ItemInputFieldTemplate;
| ItemInputFieldTemplate
| ColorInputFieldTemplate;
/**
* An output field is persisted across as part of the user's local state.
@ -193,6 +197,11 @@ export type ItemInputFieldValue = FieldValueBase & {
value?: undefined;
};
export type ColorInputFieldValue = FieldValueBase & {
type: 'color';
value?: RgbaColor;
};
export type InputFieldTemplateBase = {
name: string;
title: string;
@ -241,7 +250,7 @@ export type ImageInputFieldTemplate = InputFieldTemplateBase & {
};
export type LatentsInputFieldTemplate = InputFieldTemplateBase & {
default: undefined;
default: string;
type: 'latents';
};
@ -272,6 +281,11 @@ export type ItemInputFieldTemplate = InputFieldTemplateBase & {
type: 'item';
};
export type ColorInputFieldTemplate = InputFieldTemplateBase & {
default: RgbaColor;
type: 'color';
};
/**
* JANKY CUSTOMISATION OF OpenAPI SCHEMA TYPES
*/

View File

@ -1,6 +1,7 @@
import {
Edge,
ImageToImageInvocation,
InpaintInvocation,
IterateInvocation,
RandomRangeInvocation,
RangeInvocation,
@ -8,7 +9,7 @@ import {
} from 'services/api';
export const buildEdges = (
baseNode: TextToImageInvocation | ImageToImageInvocation,
baseNode: TextToImageInvocation | ImageToImageInvocation | InpaintInvocation,
rangeNode: RangeInvocation | RandomRangeInvocation,
iterateNode: IterateInvocation
): Edge[] => {

View File

@ -12,12 +12,13 @@ import {
ConditioningInputFieldTemplate,
StringInputFieldTemplate,
ModelInputFieldTemplate,
ArrayInputFieldTemplate,
ItemInputFieldTemplate,
ColorInputFieldTemplate,
InputFieldTemplateBase,
OutputFieldTemplate,
TypeHints,
FieldType,
ArrayInputFieldTemplate,
ItemInputFieldTemplate,
} from '../types/types';
export type BaseFieldProperties = 'name' | 'title' | 'description';
@ -233,7 +234,6 @@ const buildEnumInputFieldTemplate = ({
};
const buildArrayInputFieldTemplate = ({
schemaObject,
baseField,
}: BuildInputFieldArg): ArrayInputFieldTemplate => {
const template: ArrayInputFieldTemplate = {
@ -248,7 +248,6 @@ const buildArrayInputFieldTemplate = ({
};
const buildItemInputFieldTemplate = ({
schemaObject,
baseField,
}: BuildInputFieldArg): ItemInputFieldTemplate => {
const template: ItemInputFieldTemplate = {
@ -262,6 +261,21 @@ const buildItemInputFieldTemplate = ({
return template;
};
const buildColorInputFieldTemplate = ({
schemaObject,
baseField,
}: BuildInputFieldArg): ColorInputFieldTemplate => {
const template: ColorInputFieldTemplate = {
...baseField,
type: 'color',
inputRequirement: 'always',
inputKind: 'direct',
default: schemaObject.default ?? { r: 127, g: 127, b: 127, a: 255 },
};
return template;
};
export const getFieldType = (
schemaObject: OpenAPIV3.SchemaObject,
name: string,
@ -341,6 +355,15 @@ export const buildInputFieldTemplate = (
if (['item'].includes(fieldType)) {
return buildItemInputFieldTemplate({ schemaObject, baseField });
}
if (['color'].includes(fieldType)) {
return buildColorInputFieldTemplate({ schemaObject, baseField });
}
if (['array'].includes(fieldType)) {
return buildArrayInputFieldTemplate({ schemaObject, baseField });
}
if (['item'].includes(fieldType)) {
return buildItemInputFieldTemplate({ schemaObject, baseField });
}
return;
};

View File

@ -0,0 +1,19 @@
export const getGenerationMode = (
baseIsPartiallyTransparent: boolean,
baseIsFullyTransparent: boolean,
doesMaskHaveBlackPixels: boolean
): 'txt2img' | `img2img` | 'inpaint' | 'outpaint' => {
if (baseIsPartiallyTransparent) {
if (baseIsFullyTransparent) {
return 'txt2img';
}
return 'outpaint';
} else {
if (doesMaskHaveBlackPixels) {
return 'inpaint';
}
return 'img2img';
}
};

View File

@ -0,0 +1,141 @@
import { RootState } from 'app/store/store';
import {
Edge,
ImageToImageInvocation,
InpaintInvocation,
IterateInvocation,
RandomRangeInvocation,
RangeInvocation,
TextToImageInvocation,
} from 'services/api';
import { buildImg2ImgNode } from '../nodeBuilders/buildImageToImageNode';
import { buildTxt2ImgNode } from '../nodeBuilders/buildTextToImageNode';
import { buildRangeNode } from '../nodeBuilders/buildRangeNode';
import { buildIterateNode } from '../nodeBuilders/buildIterateNode';
import { buildEdges } from '../edgeBuilders/buildEdges';
import { getCanvasData } from 'features/canvas/util/getCanvasData';
import { getGenerationMode } from '../getGenerationMode';
import { log } from 'app/logging/useLogger';
import { buildInpaintNode } from '../nodeBuilders/buildInpaintNode';
const moduleLog = log.child({ namespace: 'buildCanvasGraph' });
const buildBaseNode = (
nodeType: 'txt2img' | 'img2img' | 'inpaint' | 'outpaint',
state: RootState
):
| TextToImageInvocation
| ImageToImageInvocation
| InpaintInvocation
| undefined => {
if (nodeType === 'txt2img') {
return buildTxt2ImgNode(state, state.canvas.boundingBoxDimensions);
}
if (nodeType === 'img2img') {
return buildImg2ImgNode(state, state.canvas.boundingBoxDimensions);
}
if (nodeType === 'inpaint' || nodeType === 'outpaint') {
return buildInpaintNode(state, state.canvas.boundingBoxDimensions);
}
};
/**
* Builds the Canvas workflow graph and image blobs.
*/
export const buildCanvasGraphAndBlobs = async (
state: RootState
): Promise<
| {
rangeNode: RangeInvocation | RandomRangeInvocation;
iterateNode: IterateInvocation;
baseNode:
| TextToImageInvocation
| ImageToImageInvocation
| InpaintInvocation;
edges: Edge[];
baseBlob: Blob;
maskBlob: Blob;
generationMode: 'txt2img' | 'img2img' | 'inpaint' | 'outpaint';
}
| undefined
> => {
const c = await getCanvasData(state);
if (!c) {
moduleLog.error('Unable to create canvas graph');
return;
}
const {
baseBlob,
maskBlob,
baseIsPartiallyTransparent,
baseIsFullyTransparent,
doesMaskHaveBlackPixels,
} = c;
moduleLog.debug(
{
data: {
baseIsPartiallyTransparent,
baseIsFullyTransparent,
doesMaskHaveBlackPixels,
},
},
'Built canvas data'
);
const generationMode = getGenerationMode(
baseIsPartiallyTransparent,
baseIsFullyTransparent,
doesMaskHaveBlackPixels
);
moduleLog.debug(`Generation mode: ${generationMode}`);
// The base node is a txt2img, img2img or inpaint node
const baseNode = buildBaseNode(generationMode, state);
if (!baseNode) {
moduleLog.error('Problem building base node');
return;
}
if (baseNode.type === 'inpaint') {
const { seamSize, seamBlur, seamSteps, seamStrength, tileSize } =
state.generation;
// generationParameters.invert_mask = shouldPreserveMaskedArea;
// if (boundingBoxScale !== 'none') {
// generationParameters.inpaint_width = scaledBoundingBoxDimensions.width;
// generationParameters.inpaint_height = scaledBoundingBoxDimensions.height;
// }
baseNode.seam_size = seamSize;
baseNode.seam_blur = seamBlur;
baseNode.seam_strength = seamStrength;
baseNode.seam_steps = seamSteps;
baseNode.tile_size = tileSize;
// baseNode.infill_method = infillMethod;
// baseNode.force_outpaint = false;
}
// We always range and iterate nodes, no matter the iteration count
// This is required to provide the correct seeds to the backend engine
const rangeNode = buildRangeNode(state);
const iterateNode = buildIterateNode();
// Build the edges for the nodes selected.
const edges = buildEdges(baseNode, rangeNode, iterateNode);
return {
rangeNode,
iterateNode,
baseNode,
edges,
baseBlob,
maskBlob,
generationMode,
};
};

View File

@ -1,19 +1,15 @@
import { RootState } from 'app/store/store';
import { Graph } from 'services/api';
import { buildImg2ImgNode } from './buildImageToImageNode';
import { buildTxt2ImgNode } from './buildTextToImageNode';
import { buildRangeNode } from './buildRangeNode';
import { buildIterateNode } from './buildIterateNode';
import { buildEdges } from './buildEdges';
import { buildImg2ImgNode } from '../nodeBuilders/buildImageToImageNode';
import { buildRangeNode } from '../nodeBuilders/buildRangeNode';
import { buildIterateNode } from '../nodeBuilders/buildIterateNode';
import { buildEdges } from '../edgeBuilders/buildEdges';
/**
* Builds the Linear workflow graph.
*/
export const buildLinearGraph = (state: RootState): Graph => {
// The base node is either a txt2img or img2img node
const baseNode = state.generation.isImageToImageEnabled
? buildImg2ImgNode(state)
: buildTxt2ImgNode(state);
export const buildImageToImageGraph = (state: RootState): Graph => {
const baseNode = buildImg2ImgNode(state);
// We always range and iterate nodes, no matter the iteration count
// This is required to provide the correct seeds to the backend engine

View File

@ -1,8 +1,30 @@
import { Graph } from 'services/api';
import { v4 as uuidv4 } from 'uuid';
import { reduce } from 'lodash-es';
import { cloneDeep, reduce } from 'lodash-es';
import { RootState } from 'app/store/store';
import { AnyInvocation } from 'services/events/types';
import { InputFieldValue } from 'features/nodes/types/types';
/**
* We need to do special handling for some fields
*/
export const parseFieldValue = (field: InputFieldValue) => {
if (field.type === 'color') {
if (field.value) {
const clonedValue = cloneDeep(field.value);
const { r, g, b, a } = field.value;
// scale alpha value to PIL's desired range 0-255
const scaledAlpha = Math.max(0, Math.min(a * 255, 255));
const transformedColor = { r, g, b, a: scaledAlpha };
Object.assign(clonedValue, transformedColor);
return clonedValue;
}
}
return field.value;
};
/**
* Builds a graph from the node editor state.
@ -20,7 +42,8 @@ export const buildNodesGraph = (state: RootState): Graph => {
const transformedInputs = reduce(
inputs,
(inputsAccumulator, input, name) => {
inputsAccumulator[name] = input.value;
const parsedValue = parseFieldValue(input);
inputsAccumulator[name] = parsedValue;
return inputsAccumulator;
},

View File

@ -0,0 +1,35 @@
import { RootState } from 'app/store/store';
import { Graph } from 'services/api';
import { buildTxt2ImgNode } from '../nodeBuilders/buildTextToImageNode';
import { buildRangeNode } from '../nodeBuilders/buildRangeNode';
import { buildIterateNode } from '../nodeBuilders/buildIterateNode';
import { buildEdges } from '../edgeBuilders/buildEdges';
/**
* Builds the Linear workflow graph.
*/
export const buildTextToImageGraph = (state: RootState): Graph => {
const baseNode = buildTxt2ImgNode(state);
// We always range and iterate nodes, no matter the iteration count
// This is required to provide the correct seeds to the backend engine
const rangeNode = buildRangeNode(state);
const iterateNode = buildIterateNode();
// Build the edges for the nodes selected.
const edges = buildEdges(baseNode, rangeNode, iterateNode);
// Assemble!
const graph = {
nodes: {
[rangeNode.id]: rangeNode,
[iterateNode.id]: iterateNode,
[baseNode.id]: baseNode,
},
edges,
};
// TODO: hires fix requires latent space upscaling; we don't have nodes for this yet
return graph;
};

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