refactor(ui): make all readiness checking async

This fixes the broken readiness checks introduced in the previous commit.

To support async batch generators, all of the validation of the generators needs to be async. This is problematic because a lot of the validation logic was in redux selectors, which are necessarily synchronous.

To resolve this, the readiness checks and related logic are restructured to be run async in response to redux state changes via `useEffect` (another option is to directly subscribe to redux store). These async functions then set some react state. The checks are debounced to prevent thrashing the UI.

See #7580 for more context about this issue.

Other changes:
- Fix a minor issue where empty collections were also checked against their min and max sizes, and errors were shown for all the checks. If a collection is empty, we don't need to do the min/max checks. If a collection is empty, we skip the other min/max checks and do not report those errors to the user.
- When a field is connected, do not attempt to check its value. This fixes an issue where collection fields with a connection could erroneously appear to be invalid.
- Improved error messages for batch nodes.
This commit is contained in:
psychedelicious
2025-02-26 09:25:56 +10:00
parent 43349cb5ce
commit 2e13bbbe1b
9 changed files with 210 additions and 174 deletions

View File

@@ -1,21 +1,21 @@
import type { TooltipProps } from '@invoke-ai/ui-library';
import { Divider, Flex, ListItem, Text, Tooltip, UnorderedList } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectIterations } from 'features/controlLayers/store/paramsSlice';
import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
import type { BatchSizeResult } from 'features/nodes/util/node/resolveBatchValue';
import { getBatchSize } from 'features/nodes/util/node/resolveBatchValue';
import type { Reason } from 'features/queue/store/readiness';
import {
$isReadyToEnqueue,
$reasonsWhyCannotEnqueue,
selectPromptsCount,
selectWorkflowsBatchSize,
} from 'features/queue/store/readiness';
import { $isReadyToEnqueue, $reasonsWhyCannotEnqueue, selectPromptsCount } from 'features/queue/store/readiness';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { debounce } from 'lodash-es';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { enqueueMutationFixedCacheKeyOptions, useEnqueueBatchMutation } from 'services/api/endpoints/queue';
import { useBoardName } from 'services/api/hooks/useBoardName';
@@ -129,10 +129,27 @@ QueueCountPredictionCanvasOrUpscaleTab.displayName = 'QueueCountPredictionCanvas
const QueueCountPredictionWorkflowsTab = memo(() => {
const { t } = useTranslation();
const batchSize = useAppSelector(selectWorkflowsBatchSize);
const dispatch = useAppDispatch();
const nodesState = useAppSelector(selectNodesSlice);
const [batchSize, setBatchSize] = useState<BatchSizeResult | 'LOADING'>('LOADING');
const debouncedUpdateBatchSize = useMemo(
() =>
debounce(async (nodesState: NodesState) => {
setBatchSize('LOADING');
const batchSize = await getBatchSize(nodesState, dispatch);
setBatchSize(batchSize);
}, 300),
[dispatch]
);
useEffect(() => {
debouncedUpdateBatchSize(nodesState);
}, [debouncedUpdateBatchSize, nodesState]);
const iterationsCount = useAppSelector(selectIterations);
const text = useMemo(() => {
if (batchSize === 'LOADING') {
return `${t('common.loading')}...`;
}
const iterations = t('queue.iterations', { count: iterationsCount });
if (batchSize === 'NO_BATCHES') {
const generationCount = Math.min(10000, iterationsCount);
@@ -140,7 +157,10 @@ const QueueCountPredictionWorkflowsTab = memo(() => {
return `${iterationsCount} ${iterations} -> ${generationCount} ${generations}`.toLowerCase();
}
if (batchSize === 'EMPTY_BATCHES') {
return t('parameters.invoke.invalidBatchConfigurationCannotCalculate');
return t('parameters.invoke.batchNodeEmptyCollection');
}
if (batchSize === 'MISMATCHED_BATCH_GROUP') {
return t('parameters.invoke.batchNodeCollectionSizeMismatchNoGroupId');
}
const generationCount = Math.min(batchSize * iterationsCount, 10000);
const generations = t('queue.generations', { count: generationCount });

View File

@@ -1,8 +1,9 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppStore } from 'app/store/nanostores/store';
import { $true } from 'app/store/nanostores/util';
import type { AppDispatch, AppStore } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import type { AppConfig } from 'app/types/invokeai';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
@@ -80,7 +81,8 @@ const debouncedUpdateReasons = debounce(
workflowSettings: WorkflowSettingsState,
templates: Templates,
upscale: UpscaleState,
config: AppConfig
config: AppConfig,
store: AppStore
) => {
if (tab === 'canvas') {
const reasons = await getReasonsWhyCannotEnqueueCanvasTab({
@@ -96,10 +98,11 @@ const debouncedUpdateReasons = debounce(
});
$reasonsWhyCannotEnqueue.set(reasons);
} else if (tab === 'workflows') {
const reasons = getReasonsWhyCannotEnqueueWorkflowsTab({
const reasons = await getReasonsWhyCannotEnqueueWorkflowsTab({
dispatch: store.dispatch,
nodesState: nodes,
workflowSettingsState: workflowSettings,
isConnected,
nodes,
workflowSettings,
templates,
});
$reasonsWhyCannotEnqueue.set(reasons);
@@ -120,6 +123,7 @@ const debouncedUpdateReasons = debounce(
export const useReadinessWatcher = () => {
useAssertSingleton('useReadinessWatcher');
const store = useAppStore();
const canvasManager = useCanvasManagerSafe();
const tab = useAppSelector(selectActiveTab);
const canvas = useAppSelector(selectCanvasSlice);
@@ -153,9 +157,11 @@ export const useReadinessWatcher = () => {
workflowSettings,
templates,
upscale,
config
config,
store
);
}, [
store,
canvas,
canvasIsCompositing,
canvasIsFiltering,
@@ -176,21 +182,23 @@ export const useReadinessWatcher = () => {
const disconnectedReason = (t: typeof i18n.t) => ({ content: t('parameters.invoke.systemDisconnected') });
const getReasonsWhyCannotEnqueueWorkflowsTab = (arg: {
const getReasonsWhyCannotEnqueueWorkflowsTab = async (arg: {
dispatch: AppDispatch;
nodesState: NodesState;
workflowSettingsState: WorkflowSettingsState;
isConnected: boolean;
nodes: NodesState;
workflowSettings: WorkflowSettingsState;
templates: Templates;
}): Reason[] => {
const { isConnected, nodes, workflowSettings, templates } = arg;
}): Promise<Reason[]> => {
const { dispatch, nodesState, workflowSettingsState, isConnected, templates } = arg;
const reasons: Reason[] = [];
if (!isConnected) {
reasons.push(disconnectedReason(i18n.t));
}
if (workflowSettings.shouldValidateGraph) {
const invocationNodes = nodes.nodes.filter(isInvocationNode);
if (workflowSettingsState.shouldValidateGraph) {
const { nodes, edges } = nodesState;
const invocationNodes = nodes.filter(isInvocationNode);
const batchNodes = invocationNodes.filter(isBatchNode);
const executableNodes = invocationNodes.filter(isExecutableNode);
@@ -199,7 +207,7 @@ const getReasonsWhyCannotEnqueueWorkflowsTab = (arg: {
}
for (const node of batchNodes) {
if (nodes.edges.find((e) => e.source === node.id) === undefined) {
if (edges.find((e) => e.source === node.id) === undefined) {
reasons.push({ content: i18n.t('parameters.invoke.batchNodeNotConnected', { label: node.data.label }) });
}
}
@@ -212,7 +220,7 @@ const getReasonsWhyCannotEnqueueWorkflowsTab = (arg: {
const groupBatchSizes: number[] = [];
for (const node of batchNodes) {
const size = resolveBatchValue(node, invocationNodes, nodes.edges).length;
const size = (await resolveBatchValue({ dispatch, nodesState, node })).length;
if (batchGroupId === 'None') {
// Ungrouped batch nodes may have differing collection sizes
batchSizes.push(size);
@@ -237,12 +245,12 @@ const getReasonsWhyCannotEnqueueWorkflowsTab = (arg: {
}
}
executableNodes.forEach((node) => {
invocationNodes.forEach((node) => {
if (!isInvocationNode(node)) {
return;
}
const errors = getInvocationNodeErrors(node.data.id, templates, nodes);
const errors = getInvocationNodeErrors(node.data.id, templates, nodesState);
for (const error of errors) {
if (error.type === 'node-error') {
@@ -490,81 +498,3 @@ export const selectPromptsCount = createSelector(
selectDynamicPromptsSlice,
(params, dynamicPrompts) => (getShouldProcessPrompt(params.positivePrompt) ? dynamicPrompts.prompts.length : 1)
);
const buildSelectGroupBatchSizes = (batchGroupId: string) =>
createMemoizedSelector(selectNodesSlice, ({ nodes, edges }) => {
const invocationNodes = nodes.filter(isInvocationNode);
return invocationNodes
.filter(isBatchNode)
.filter((node) => node.data.inputs['batch_group_id']?.value === batchGroupId)
.map((batchNodes) => resolveBatchValue(batchNodes, invocationNodes, edges).length);
});
const selectUngroupedBatchSizes = buildSelectGroupBatchSizes('None');
const selectGroup1BatchSizes = buildSelectGroupBatchSizes('Group 1');
const selectGroup2BatchSizes = buildSelectGroupBatchSizes('Group 2');
const selectGroup3BatchSizes = buildSelectGroupBatchSizes('Group 3');
const selectGroup4BatchSizes = buildSelectGroupBatchSizes('Group 4');
const selectGroup5BatchSizes = buildSelectGroupBatchSizes('Group 5');
export const selectWorkflowsBatchSize = createSelector(
selectUngroupedBatchSizes,
selectGroup1BatchSizes,
selectGroup2BatchSizes,
selectGroup3BatchSizes,
selectGroup4BatchSizes,
selectGroup5BatchSizes,
(
ungroupedBatchSizes,
group1BatchSizes,
group2BatchSizes,
group3BatchSizes,
group4BatchSizes,
group5BatchSizes
): number | 'EMPTY_BATCHES' | 'NO_BATCHES' => {
// All batch nodes _must_ have a populated collection
const allBatchSizes = [
...ungroupedBatchSizes,
...group1BatchSizes,
...group2BatchSizes,
...group3BatchSizes,
...group4BatchSizes,
...group5BatchSizes,
];
// There are no batch nodes
if (allBatchSizes.length === 0) {
return 'NO_BATCHES';
}
// All batch nodes must have a populated collection
if (allBatchSizes.some((size) => size === 0)) {
return 'EMPTY_BATCHES';
}
for (const group of [group1BatchSizes, group2BatchSizes, group3BatchSizes, group4BatchSizes, group5BatchSizes]) {
// Ignore groups with no batch nodes
if (group.length === 0) {
continue;
}
// Grouped batch nodes must have the same collection size
if (group.some((size) => size !== group[0])) {
return 'EMPTY_BATCHES';
}
}
// Total batch size = product of all ungrouped batches and each grouped batch
const totalBatchSize = [
...ungroupedBatchSizes,
// In case of no batch nodes in a group, fall back to 1 for the product calculation
group1BatchSizes[0] ?? 1,
group2BatchSizes[0] ?? 1,
group3BatchSizes[0] ?? 1,
group4BatchSizes[0] ?? 1,
group5BatchSizes[0] ?? 1,
].reduce((acc, size) => acc * size, 1);
return totalBatchSize;
}
);