diff --git a/invokeai/frontend/web/src/common/components/IAIButton.tsx b/invokeai/frontend/web/src/common/components/IAIButton.tsx index d1e77537cc..4058296aaf 100644 --- a/invokeai/frontend/web/src/common/components/IAIButton.tsx +++ b/invokeai/frontend/web/src/common/components/IAIButton.tsx @@ -8,8 +8,8 @@ import { import { memo, ReactNode } from 'react'; export interface IAIButtonProps extends ButtonProps { - tooltip?: string; - tooltipProps?: Omit; + tooltip?: TooltipProps['label']; + tooltipProps?: Omit; isChecked?: boolean; children: ReactNode; } diff --git a/invokeai/frontend/web/src/common/components/IAIIconButton.tsx b/invokeai/frontend/web/src/common/components/IAIIconButton.tsx index ed1514055e..0a42430689 100644 --- a/invokeai/frontend/web/src/common/components/IAIIconButton.tsx +++ b/invokeai/frontend/web/src/common/components/IAIIconButton.tsx @@ -9,8 +9,8 @@ import { memo } from 'react'; export type IAIIconButtonProps = IconButtonProps & { role?: string; - tooltip?: string; - tooltipProps?: Omit; + tooltip?: TooltipProps['label']; + tooltipProps?: Omit; isChecked?: boolean; }; diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts index f43ec1851f..ac770e3787 100644 --- a/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts +++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToInvoke.ts @@ -2,71 +2,104 @@ import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -// import { validateSeedWeights } from 'common/util/seedWeightPairs'; +import { isInvocationNode } from 'features/nodes/types/types'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; -import { forEach } from 'lodash-es'; -import { NON_REFINER_BASE_MODELS } from 'services/api/constants'; -import { modelsApi } from '../../services/api/endpoints/models'; +import { forEach, map } from 'lodash-es'; +import { getConnectedEdges } from 'reactflow'; -const readinessSelector = createSelector( +const selector = createSelector( [stateSelector, activeTabNameSelector], (state, activeTabName) => { - const { generation, system } = state; - const { initialImage } = generation; + const { generation, system, nodes } = state; + const { initialImage, model } = generation; const { isProcessing, isConnected } = system; - let isReady = true; - const reasonsWhyNotReady: string[] = []; + const reasons: string[] = []; - if (activeTabName === 'img2img' && !initialImage) { - isReady = false; - reasonsWhyNotReady.push('No initial image selected'); - } - - const { isSuccess: mainModelsSuccessfullyLoaded } = - modelsApi.endpoints.getMainModels.select(NON_REFINER_BASE_MODELS)(state); - if (!mainModelsSuccessfullyLoaded) { - isReady = false; - reasonsWhyNotReady.push('Models are not loaded'); - } - - // TODO: job queue // Cannot generate if already processing an image if (isProcessing) { - isReady = false; - reasonsWhyNotReady.push('System Busy'); + reasons.push('System busy'); } // Cannot generate if not connected if (!isConnected) { - isReady = false; - reasonsWhyNotReady.push('System Disconnected'); + reasons.push('System disconnected'); } - // // Cannot generate variations without valid seed weights - // if ( - // shouldGenerateVariations && - // (!(validateSeedWeights(seedWeights) || seedWeights === '') || seed === -1) - // ) { - // isReady = false; - // reasonsWhyNotReady.push('Seed-Weights badly formatted.'); - // } + if (activeTabName === 'img2img' && !initialImage) { + reasons.push('No initial image selected'); + } - forEach(state.controlNet.controlNets, (controlNet, id) => { - if (!controlNet.model) { - isReady = false; - reasonsWhyNotReady.push(`ControlNet ${id} has no model selected.`); + if (activeTabName === 'nodes' && nodes.shouldValidateGraph) { + nodes.nodes.forEach((node) => { + if (!isInvocationNode(node)) { + return; + } + + const nodeTemplate = nodes.nodeTemplates[node.data.type]; + + if (!nodeTemplate) { + // Node type not found + reasons.push('Missing node template'); + return; + } + + const connectedEdges = getConnectedEdges([node], nodes.edges); + + forEach(node.data.inputs, (field) => { + const fieldTemplate = nodeTemplate.inputs[field.name]; + const hasConnection = connectedEdges.some( + (edge) => + edge.target === node.id && edge.targetHandle === field.name + ); + + if (!fieldTemplate) { + reasons.push('Missing field template'); + return; + } + + if (fieldTemplate.required && !field.value && !hasConnection) { + reasons.push( + `${node.data.label || nodeTemplate.title} -> ${ + field.label || fieldTemplate.title + } missing input` + ); + return; + } + }); + }); + } else { + if (!model) { + reasons.push('No model selected'); } - }); - // All good - return { isReady, reasonsWhyNotReady }; + if (state.controlNet.isEnabled) { + map(state.controlNet.controlNets).forEach((controlNet, i) => { + if (!controlNet.isEnabled) { + return; + } + if (!controlNet.model) { + reasons.push(`ControlNet ${i + 1} has no model selected.`); + } + + if ( + !controlNet.controlImage || + (!controlNet.processedControlImage && + controlNet.processorType !== 'none') + ) { + reasons.push(`ControlNet ${i + 1} has no control image`); + } + }); + } + } + + return { isReady: !reasons.length, isProcessing, reasons }; }, defaultSelectorOptions ); export const useIsReadyToInvoke = () => { - const { isReady } = useAppSelector(readinessSelector); - return isReady; + const { isReady, isProcessing, reasons } = useAppSelector(selector); + return { isReady, isProcessing, reasons }; }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/NodeInvokeButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/NodeInvokeButton.tsx index f207e910b1..decaea19e8 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/NodeInvokeButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/NodeInvokeButton.tsx @@ -2,10 +2,9 @@ import { Box } from '@chakra-ui/react'; import { userInvoked } from 'app/store/actions'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIButton, { IAIButtonProps } from 'common/components/IAIButton'; -import IAIIconButton, { - IAIIconButtonProps, -} from 'common/components/IAIIconButton'; -import { selectIsReadyNodes } from 'features/nodes/store/selectors'; +import { IAIIconButtonProps } from 'common/components/IAIIconButton'; +import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke'; +import { InvokeButtonTooltipContent } from 'features/parameters/components/ProcessButtons/InvokeButton'; import ProgressBar from 'features/system/components/ProgressBar'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { memo, useCallback } from 'react'; @@ -14,15 +13,13 @@ import { useTranslation } from 'react-i18next'; import { FaPlay } from 'react-icons/fa'; interface InvokeButton - extends Omit { - iconButton?: boolean; -} + extends Omit {} const NodeInvokeButton = (props: InvokeButton) => { - const { iconButton = false, ...rest } = props; + const { ...rest } = props; const dispatch = useAppDispatch(); const activeTabName = useAppSelector(activeTabNameSelector); - const isReady = useAppSelector(selectIsReadyNodes); + const { isReady, isProcessing } = useIsReadyToInvoke(); const handleInvoke = useCallback(() => { dispatch(userInvoked('nodes')); }, [dispatch]); @@ -58,37 +55,24 @@ const NodeInvokeButton = (props: InvokeButton) => { )} - {iconButton ? ( - } - isDisabled={!isReady} - onClick={handleInvoke} - flexGrow={1} - w="100%" - tooltip={t('parameters.invoke')} - tooltipProps={{ placement: 'bottom' }} - colorScheme="accent" - id="invoke-button" - {...rest} - /> - ) : ( - - Invoke - - )} + } + aria-label={t('parameters.invoke')} + type="submit" + isDisabled={!isReady} + onClick={handleInvoke} + flexGrow={1} + w="100%" + colorScheme="accent" + id="invoke-button" + leftIcon={isProcessing ? undefined : } + fontWeight={700} + isLoading={isProcessing} + loadingText={t('parameters.invoke')} + {...rest} + > + Invoke + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx index 5f00604afa..67471b7e3d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx @@ -1,8 +1,7 @@ import { useAppDispatch } from 'app/store/storeHooks'; -import IAIIconButton from 'common/components/IAIIconButton'; +import IAIButton from 'common/components/IAIButton'; import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice'; import { memo, useCallback } from 'react'; -import { FaPlus } from 'react-icons/fa'; import { Panel } from 'reactflow'; const TopLeftPanel = () => { @@ -14,12 +13,9 @@ const TopLeftPanel = () => { return ( - } - /> + + Add Node + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/store/selectors.ts b/invokeai/frontend/web/src/features/nodes/store/selectors.ts deleted file mode 100644 index 41a608baa3..0000000000 --- a/invokeai/frontend/web/src/features/nodes/store/selectors.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -// import { validateSeedWeights } from 'common/util/seedWeightPairs'; -import { every } from 'lodash-es'; -import { getConnectedEdges } from 'reactflow'; -import { isInvocationNode } from '../types/types'; -import { NodesState } from './types'; - -export const selectIsReadyNodes = createSelector( - [stateSelector], - (state) => { - const { nodes, system } = state; - const { isProcessing, isConnected } = system; - - if (isProcessing || !isConnected) { - // Cannot generate if already processing an image - return false; - } - - if (!nodes.shouldValidateGraph) { - return true; - } - - const isGraphReady = every(nodes.nodes, (node) => { - if (!isInvocationNode(node)) { - return true; - } - - const nodeTemplate = nodes.nodeTemplates[node.data.type]; - - if (!nodeTemplate) { - // Node type not found - return false; - } - - const connectedEdges = getConnectedEdges([node], nodes.edges); - - const isNodeValid = every(node.data.inputs, (field) => { - const fieldTemplate = nodeTemplate.inputs[field.name]; - const hasConnection = connectedEdges.some( - (edge) => edge.target === node.id && edge.targetHandle === field.name - ); - - if (!fieldTemplate) { - // Field type not found - return false; - } - - if (fieldTemplate.required && !field.value && !hasConnection) { - // Required field is empty or does not have a connection - return false; - } - - // ok - return true; - }); - - return isNodeValid; - }); - - return isGraphReady; - }, - defaultSelectorOptions -); - -export const getNodeAndTemplate = (nodeId: string, nodes: NodesState) => { - const node = nodes.nodes.find((node) => node.id === nodeId); - const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? '']; - - return { node, nodeTemplate }; -}; - -export const getInputFieldAndTemplate = ( - nodeId: string, - fieldName: string, - nodes: NodesState -) => { - const node = nodes.nodes - .filter(isInvocationNode) - .find((node) => node.id === nodeId); - const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? '']; - - if (!node || !nodeTemplate) { - return; - } - - const field = node.data.inputs[fieldName]; - const fieldTemplate = nodeTemplate.inputs[fieldName]; - - return { field, fieldTemplate }; -}; diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx index 3880f717b9..2332a91c7d 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx @@ -1,4 +1,12 @@ -import { Box, ChakraProps, Tooltip } from '@chakra-ui/react'; +import { + Box, + ChakraProps, + Divider, + Flex, + ListItem, + Text, + UnorderedList, +} from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { userInvoked } from 'app/store/actions'; import { stateSelector } from 'app/store/store'; @@ -13,7 +21,7 @@ import { clampSymmetrySteps } from 'features/parameters/store/generationSlice'; import ProgressBar from 'features/system/components/ProgressBar'; import { selectIsBusy } from 'features/system/store/systemSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; -import { useCallback } from 'react'; +import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { FaPlay } from 'react-icons/fa'; @@ -53,9 +61,8 @@ interface InvokeButton export default function InvokeButton(props: InvokeButton) { const { iconButton = false, ...rest } = props; const dispatch = useAppDispatch(); - const isReady = useIsReadyToInvoke(); - const { isBusy, autoAddBoardId, activeTabName } = useAppSelector(selector); - const autoAddBoardName = useBoardName(autoAddBoardId); + const { isReady, isProcessing } = useIsReadyToInvoke(); + const { activeTabName } = useAppSelector(selector); const handleInvoke = useCallback(() => { dispatch(clampSymmetrySteps()); @@ -94,53 +101,80 @@ export default function InvokeButton(props: InvokeButton) { )} - - {iconButton ? ( - } - isDisabled={!isReady || isBusy} - onClick={handleInvoke} - tooltip={t('parameters.invoke')} - tooltipProps={{ placement: 'top' }} - colorScheme="accent" - id="invoke-button" - {...rest} - sx={{ - w: 'full', - flexGrow: 1, - ...(isBusy ? IN_PROGRESS_STYLES : {}), - }} - /> - ) : ( - - Invoke - - )} - + {iconButton ? ( + } + isDisabled={!isReady} + onClick={handleInvoke} + tooltip={} + colorScheme="accent" + isLoading={isProcessing} + id="invoke-button" + {...rest} + sx={{ + w: 'full', + flexGrow: 1, + ...(isProcessing ? IN_PROGRESS_STYLES : {}), + }} + /> + ) : ( + } + aria-label={t('parameters.invoke')} + type="submit" + isDisabled={!isReady} + onClick={handleInvoke} + colorScheme="accent" + id="invoke-button" + leftIcon={isProcessing ? undefined : } + isLoading={isProcessing} + loadingText={t('parameters.invoke')} + {...rest} + sx={{ + w: 'full', + flexGrow: 1, + fontWeight: 700, + ...(isProcessing ? IN_PROGRESS_STYLES : {}), + }} + > + Invoke + + )} ); } + +export const InvokeButtonTooltipContent = memo(() => { + const { isReady, reasons } = useIsReadyToInvoke(); + const { autoAddBoardId } = useAppSelector(selector); + const autoAddBoardName = useBoardName(autoAddBoardId); + + return ( + + + {isReady ? 'Ready to Invoke' : 'Unable to Invoke'} + + {reasons.length > 0 && ( + + {reasons.map((reason, i) => ( + + {reason} + + ))} + + )} + + + Adding images to{' '} + + {autoAddBoardName || 'Uncategorized'} + + + + ); +}); + +InvokeButtonTooltipContent.displayName = 'InvokeButtonTooltipContent';