diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index b36fe0e26c..7aa4b03b8c 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -261,7 +261,6 @@ "queue": "Queue", "queueFront": "Add to Front of Queue", "queueBack": "Add to Queue", - "queueCountPrediction": "{{promptsCount}} prompts \u00d7 {{iterations}} iterations -> {{count}} generations", "queueEmpty": "Queue Empty", "enqueueing": "Queueing Batch", "resume": "Resume", @@ -314,7 +313,13 @@ "batchFailedToQueue": "Failed to Queue Batch", "graphQueued": "Graph queued", "graphFailedToQueue": "Failed to queue graph", - "openQueue": "Open Queue" + "openQueue": "Open Queue", + "prompts_one": "Prompt", + "prompts_other": "Prompts", + "iterations_one": "Iteration", + "iterations_other": "Iterations", + "generations_one": "Generation", + "generations_other": "Generations" }, "invocationCache": { "invocationCache": "Invocation Cache", @@ -934,7 +939,20 @@ "noModelSelected": "No model selected", "noPrompts": "No prompts generated", "noNodesInGraph": "No nodes in graph", - "systemDisconnected": "System disconnected" + "systemDisconnected": "System disconnected", + "layer": { + "initialImageNoImageSelected": "no initial image selected", + "controlAdapterNoModelSelected": "no Control Adapter model selected", + "controlAdapterIncompatibleBaseModel": "incompatible Control Adapter base model", + "controlAdapterNoImageSelected": "no Control Adapter image selected", + "controlAdapterImageNotProcessed": "Control Adapter image not processed", + "t2iAdapterIncompatibleDimensions": "T2I Adapter requires image dimension to be multiples of 64", + "ipAdapterNoModelSelected": "no IP adapter selected", + "ipAdapterIncompatibleBaseModel": "incompatible IP Adapter base model", + "ipAdapterNoImageSelected": "no IP Adapter image selected", + "rgNoPromptsOrIPAdapters": "no text prompts or IP Adapters", + "rgNoRegion": "no region selected" + } }, "maskBlur": "Mask Blur", "negativePromptPlaceholder": "Negative Prompt", @@ -945,8 +963,6 @@ "positivePromptPlaceholder": "Positive Prompt", "globalPositivePromptPlaceholder": "Global Positive Prompt", "iterations": "Iterations", - "iterationsWithCount_one": "{{count}} Iteration", - "iterationsWithCount_other": "{{count}} Iterations", "scale": "Scale", "scaleBeforeProcessing": "Scale Before Processing", "scaledHeight": "Scaled H", diff --git a/invokeai/frontend/web/src/common/hooks/useGroupedModelCombobox.ts b/invokeai/frontend/web/src/common/hooks/useGroupedModelCombobox.ts index 55887eb3be..5b57fcd2bb 100644 --- a/invokeai/frontend/web/src/common/hooks/useGroupedModelCombobox.ts +++ b/invokeai/frontend/web/src/common/hooks/useGroupedModelCombobox.ts @@ -13,6 +13,7 @@ type UseGroupedModelComboboxArg = { onChange: (value: T | null) => void; getIsDisabled?: (model: T) => boolean; isLoading?: boolean; + groupByType?: boolean; }; type UseGroupedModelComboboxReturn = { @@ -23,17 +24,21 @@ type UseGroupedModelComboboxReturn = { noOptionsMessage: () => string; }; +const groupByBaseFunc = (model: T) => model.base.toUpperCase(); +const groupByBaseAndTypeFunc = (model: T) => + `${model.base.toUpperCase()} / ${model.type.replaceAll('_', ' ').toUpperCase()}`; + export const useGroupedModelCombobox = ( arg: UseGroupedModelComboboxArg ): UseGroupedModelComboboxReturn => { const { t } = useTranslation(); const base_model = useAppSelector((s) => s.generation.model?.base ?? 'sdxl'); - const { modelConfigs, selectedModel, getIsDisabled, onChange, isLoading } = arg; + const { modelConfigs, selectedModel, getIsDisabled, onChange, isLoading, groupByType = false } = arg; const options = useMemo[]>(() => { if (!modelConfigs) { return []; } - const groupedModels = groupBy(modelConfigs, 'base'); + const groupedModels = groupBy(modelConfigs, groupByType ? groupByBaseAndTypeFunc : groupByBaseFunc); const _options = reduce( groupedModels, (acc, val, label) => { @@ -49,9 +54,9 @@ export const useGroupedModelCombobox = ( }, [] as GroupBase[] ); - _options.sort((a) => (a.label === base_model ? -1 : 1)); + _options.sort((a) => (a.label?.split('/')[0]?.toLowerCase().includes(base_model) ? -1 : 1)); return _options; - }, [getIsDisabled, modelConfigs, base_model]); + }, [modelConfigs, groupByType, getIsDisabled, base_model]); const value = useMemo( () => diff --git a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts index 2aac5b8e72..3c863d0c93 100644 --- a/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts +++ b/invokeai/frontend/web/src/common/hooks/useIsReadyToEnqueue.ts @@ -6,6 +6,7 @@ import { } from 'features/controlAdapters/store/controlAdaptersSlice'; import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types'; import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice'; +import type { Layer } from 'features/controlLayers/store/types'; import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt'; import { selectNodesSlice } from 'features/nodes/store/nodesSlice'; @@ -14,9 +15,16 @@ import { selectGenerationSlice } from 'features/parameters/store/generationSlice import { selectSystemSlice } from 'features/system/store/systemSlice'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import i18n from 'i18next'; -import { forEach } from 'lodash-es'; +import { forEach, upperFirst } from 'lodash-es'; import { getConnectedEdges } from 'reactflow'; +const LAYER_TYPE_TO_TKEY: Record = { + initial_image_layer: 'controlLayers.globalInitialImage', + control_adapter_layer: 'controlLayers.globalControlAdapter', + ip_adapter_layer: 'controlLayers.globalIPAdapter', + regional_guidance_layer: 'controlLayers.regionalGuidance', +}; + const selector = createMemoizedSelector( [ selectControlAdaptersSlice, @@ -29,21 +37,22 @@ const selector = createMemoizedSelector( ], (controlAdapters, generation, system, nodes, dynamicPrompts, controlLayers, activeTabName) => { const { model } = generation; + const { size } = controlLayers.present; const { positivePrompt } = controlLayers.present; const { isConnected } = system; - const reasons: string[] = []; + const reasons: { prefix?: string; content: string }[] = []; // Cannot generate if not connected if (!isConnected) { - reasons.push(i18n.t('parameters.invoke.systemDisconnected')); + reasons.push({ content: i18n.t('parameters.invoke.systemDisconnected') }); } if (activeTabName === 'workflows') { if (nodes.shouldValidateGraph) { if (!nodes.nodes.length) { - reasons.push(i18n.t('parameters.invoke.noNodesInGraph')); + reasons.push({ content: i18n.t('parameters.invoke.noNodesInGraph') }); } nodes.nodes.forEach((node) => { @@ -55,7 +64,7 @@ const selector = createMemoizedSelector( if (!nodeTemplate) { // Node type not found - reasons.push(i18n.t('parameters.invoke.missingNodeTemplate')); + reasons.push({ content: i18n.t('parameters.invoke.missingNodeTemplate') }); return; } @@ -68,17 +77,17 @@ const selector = createMemoizedSelector( ); if (!fieldTemplate) { - reasons.push(i18n.t('parameters.invoke.missingFieldTemplate')); + reasons.push({ content: i18n.t('parameters.invoke.missingFieldTemplate') }); return; } if (fieldTemplate.required && field.value === undefined && !hasConnection) { - reasons.push( - i18n.t('parameters.invoke.missingInputForField', { + reasons.push({ + content: i18n.t('parameters.invoke.missingInputForField', { nodeLabel: node.data.label || nodeTemplate.title, fieldLabel: field.label || fieldTemplate.title, - }) - ); + }), + }); return; } }); @@ -86,62 +95,94 @@ const selector = createMemoizedSelector( } } else { if (dynamicPrompts.prompts.length === 0 && getShouldProcessPrompt(positivePrompt)) { - reasons.push(i18n.t('parameters.invoke.noPrompts')); + reasons.push({ content: i18n.t('parameters.invoke.noPrompts') }); } if (!model) { - reasons.push(i18n.t('parameters.invoke.noModelSelected')); + reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') }); } if (activeTabName === 'generation') { // Handling for generation tab controlLayers.present.layers .filter((l) => l.isEnabled) - .flatMap((l) => { + .forEach((l, i) => { + const layerLiteral = i18n.t('controlLayers.layers_one'); + const layerNumber = i + 1; + const layerType = i18n.t(LAYER_TYPE_TO_TKEY[l.type]); + const prefix = `${layerLiteral} #${layerNumber} (${layerType})`; + const problems: string[] = []; if (l.type === 'control_adapter_layer') { - return l.controlAdapter; - } else if (l.type === 'ip_adapter_layer') { - return l.ipAdapter; - } else if (l.type === 'regional_guidance_layer') { - return l.ipAdapters; + // Must have model + if (!l.controlAdapter.model) { + problems.push(i18n.t('parameters.invoke.layer.controlAdapterNoModelSelected')); + } + // Model base must match + if (l.controlAdapter.model?.base !== model?.base) { + problems.push(i18n.t('parameters.invoke.layer.controlAdapterIncompatibleBaseModel')); + } + // Must have a control image OR, if it has a processor, it must have a processed image + if (!l.controlAdapter.image) { + problems.push(i18n.t('parameters.invoke.layer.controlAdapterNoImageSelected')); + } else if (l.controlAdapter.processorConfig && !l.controlAdapter.processedImage) { + problems.push(i18n.t('parameters.invoke.layer.controlAdapterImageNotProcessed')); + } + // T2I Adapters require images have dimensions that are multiples of 64 + if (l.controlAdapter.type === 't2i_adapter' && (size.width % 64 !== 0 || size.height % 64 !== 0)) { + problems.push(i18n.t('parameters.invoke.layer.t2iAdapterIncompatibleDimensions')); + } } - return []; - }) - .forEach((ca, i) => { - const hasNoModel = !ca.model; - const mismatchedModelBase = ca.model?.base !== model?.base; - const hasNoImage = !ca.image; - const imageNotProcessed = - (ca.type === 'controlnet' || ca.type === 't2i_adapter') && !ca.processedImage && ca.processorConfig; - if (hasNoModel) { - reasons.push( - i18n.t('parameters.invoke.noModelForControlAdapter', { - number: i + 1, - }) - ); + if (l.type === 'ip_adapter_layer') { + // Must have model + if (!l.ipAdapter.model) { + problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoModelSelected')); + } + // Model base must match + if (l.ipAdapter.model?.base !== model?.base) { + problems.push(i18n.t('parameters.invoke.layer.ipAdapterIncompatibleBaseModel')); + } + // Must have an image + if (!l.ipAdapter.image) { + problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoImageSelected')); + } } - if (mismatchedModelBase) { - // This should never happen, just a sanity check - reasons.push( - i18n.t('parameters.invoke.incompatibleBaseModelForControlAdapter', { - number: i + 1, - }) - ); + + if (l.type === 'initial_image_layer') { + // Must have an image + if (!l.image) { + problems.push(i18n.t('parameters.invoke.layer.initialImageNoImageSelected')); + } } - if (hasNoImage) { - reasons.push( - i18n.t('parameters.invoke.noControlImageForControlAdapter', { - number: i + 1, - }) - ); + + if (l.type === 'regional_guidance_layer') { + // Must have a region + if (l.maskObjects.length === 0) { + problems.push(i18n.t('parameters.invoke.layer.rgNoRegion')); + } + // Must have at least 1 prompt or IP Adapter + if (l.positivePrompt === null && l.negativePrompt === null && l.ipAdapters.length === 0) { + problems.push(i18n.t('parameters.invoke.layer.rgNoPromptsOrIPAdapters')); + } + l.ipAdapters.forEach((ipAdapter) => { + // Must have model + if (!ipAdapter.model) { + problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoModelSelected')); + } + // Model base must match + if (ipAdapter.model?.base !== model?.base) { + problems.push(i18n.t('parameters.invoke.layer.ipAdapterIncompatibleBaseModel')); + } + // Must have an image + if (!ipAdapter.image) { + problems.push(i18n.t('parameters.invoke.layer.ipAdapterNoImageSelected')); + } + }); } - if (imageNotProcessed) { - reasons.push( - i18n.t('parameters.invoke.imageNotProcessedForControlAdapter', { - number: i + 1, - }) - ); + + if (problems.length) { + const content = upperFirst(problems.join(', ')); + reasons.push({ prefix, content }); } }); } else { @@ -154,29 +195,19 @@ const selector = createMemoizedSelector( } if (!ca.model) { - reasons.push( - i18n.t('parameters.invoke.noModelForControlAdapter', { - number: i + 1, - }) - ); + reasons.push({ content: i18n.t('parameters.invoke.noModelForControlAdapter', { number: i + 1 }) }); } else if (ca.model.base !== model?.base) { // This should never happen, just a sanity check - reasons.push( - i18n.t('parameters.invoke.incompatibleBaseModelForControlAdapter', { - number: i + 1, - }) - ); + reasons.push({ + content: i18n.t('parameters.invoke.incompatibleBaseModelForControlAdapter', { number: i + 1 }), + }); } if ( !ca.controlImage || (isControlNetOrT2IAdapter(ca) && !ca.processedControlImage && ca.processorType !== 'none') ) { - reasons.push( - i18n.t('parameters.invoke.noControlImageForControlAdapter', { - number: i + 1, - }) - ); + reasons.push({ content: i18n.t('parameters.invoke.noControlImageForControlAdapter', { number: i + 1 }) }); } }); } @@ -187,6 +218,6 @@ const selector = createMemoizedSelector( ); export const useIsReadyToEnqueue = () => { - const { isReady, reasons } = useAppSelector(selector); - return { isReady, reasons }; + const value = useAppSelector(selector); + return value; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterModelCombobox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterModelCombobox.tsx index a4b1d6b744..535f3067a4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterModelCombobox.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlAndIPAdapter/ControlAdapterModelCombobox.tsx @@ -42,6 +42,7 @@ export const ControlAdapterModelCombobox = memo(({ modelKey, onChange: onChangeM selectedModel, getIsDisabled, isLoading, + groupByType: true, }); return ( diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx index 314616da59..52dc5e24af 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx @@ -16,25 +16,26 @@ export const InvokeQueueBackButton = memo(() => { return ( - + + + ); }); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx index f63e96c45f..498414d377 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueButtonTooltip.tsx @@ -1,10 +1,11 @@ -import { Divider, Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library'; +import { Divider, Flex, ListItem, Text, Tooltip, UnorderedList } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; import { useIsReadyToEnqueue } from 'common/hooks/useIsReadyToEnqueue'; import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice'; import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt'; +import type { PropsWithChildren } from 'react'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useEnqueueBatchMutation } from 'services/api/endpoints/queue'; @@ -21,17 +22,32 @@ type Props = { prepend?: boolean; }; -export const QueueButtonTooltip = memo(({ prepend = false }: Props) => { +export const QueueButtonTooltip = (props: PropsWithChildren) => { + return ( + } maxW={512}> + {props.children} + + ); +}; + +const TooltipContent = memo(({ prepend = false }: Props) => { const { t } = useTranslation(); const { isReady, reasons } = useIsReadyToEnqueue(); const isLoadingDynamicPrompts = useAppSelector((s) => s.dynamicPrompts.isLoading); const promptsCount = useAppSelector(selectPromptsCount); - const iterations = useAppSelector((s) => s.generation.iterations); + const iterationsCount = useAppSelector((s) => s.generation.iterations); const autoAddBoardId = useAppSelector((s) => s.gallery.autoAddBoardId); const autoAddBoardName = useBoardName(autoAddBoardId); const [_, { isLoading }] = useEnqueueBatchMutation({ fixedCacheKey: 'enqueueBatch', }); + const queueCountPredictionLabel = useMemo(() => { + const generationCount = Math.min(promptsCount * iterationsCount, 10000); + const prompts = t('queue.prompts', { count: promptsCount }); + const iterations = t('queue.iterations', { count: iterationsCount }); + const generations = t('queue.generations', { count: generationCount }); + return `${promptsCount} ${prompts} \u00d7 ${iterationsCount} ${iterations} -> ${generationCount} ${generations}`.toLowerCase(); + }, [iterationsCount, promptsCount, t]); const label = useMemo(() => { if (isLoading) { @@ -52,20 +68,21 @@ export const QueueButtonTooltip = memo(({ prepend = false }: Props) => { return ( {label} - - {t('queue.queueCountPrediction', { - promptsCount, - iterations, - count: Math.min(promptsCount * iterations, 10000), - })} - + {queueCountPredictionLabel} {reasons.length > 0 && ( <> {reasons.map((reason, i) => ( - - {reason} + + + {reason.prefix && ( + + {reason.prefix}:{' '} + + )} + {reason.content} + ))} @@ -82,4 +99,4 @@ export const QueueButtonTooltip = memo(({ prepend = false }: Props) => { ); }); -QueueButtonTooltip.displayName = 'QueueButtonTooltip'; +TooltipContent.displayName = 'QueueButtonTooltipContent'; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueFrontButton.tsx b/invokeai/frontend/web/src/features/queue/components/QueueFrontButton.tsx index 07ad0f5b3c..eb0e72950f 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueFrontButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueFrontButton.tsx @@ -10,15 +10,16 @@ const QueueFrontButton = () => { const { t } = useTranslation(); const { queueFront, isLoading, isDisabled } = useQueueFront(); return ( - } - icon={} - size="lg" - /> + + } + size="lg" + /> + ); }; diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx index 32611b2354..5a8273b7fc 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx @@ -63,16 +63,17 @@ const FloatingSidePanelButtons = (props: Props) => { sx={floatingButtonStyles} icon={} /> - } - sx={floatingButtonStyles} - /> + + +