diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 7a2ecc7e5a..748082ae28 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1768,7 +1768,9 @@ "containerPlaceholder": "Empty Container", "headingPlaceholder": "Empty Heading", "textPlaceholder": "Empty Text", - "workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release." + "workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release.", + "minimum": "Minimum", + "maximum": "Maximum" } }, "controlLayers": { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx index 3d8c1228dc..e7df094ee3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx @@ -3,24 +3,35 @@ import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/f import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types'; import { NO_DRAG_CLASS } from 'features/nodes/types/constants'; import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field'; +import type { NodeFieldFloatSettings } from 'features/nodes/types/workflow'; import { memo } from 'react'; -export const FloatFieldInput = memo((props: FieldComponentProps) => { - const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props); +export const FloatFieldInput = memo( + ( + props: FieldComponentProps + ) => { + const { nodeId, field, fieldTemplate, settings } = props; + const { defaultValue, onChange, min, max, step, fineStep } = useFloatField( + nodeId, + field.name, + fieldTemplate, + settings + ); - return ( - - ); -}); + return ( + + ); + } +); FloatFieldInput.displayName = 'FloatFieldInput '; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider.tsx index e52ef5e388..df9ff4eeab 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInputAndSlider.tsx @@ -3,18 +3,27 @@ import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/f import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types'; import { NO_DRAG_CLASS } from 'features/nodes/types/constants'; import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field'; +import type { NodeFieldFloatSettings } from 'features/nodes/types/workflow'; import { memo } from 'react'; export const FloatFieldInputAndSlider = memo( - (props: FieldComponentProps) => { - const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props); + ( + props: FieldComponentProps + ) => { + const { nodeId, field, fieldTemplate, settings } = props; + const { defaultValue, onChange, min, max, step, fineStep } = useFloatField( + nodeId, + field.name, + fieldTemplate, + settings + ); return ( <> ) => { - const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props); +export const FloatFieldSlider = memo( + ( + props: FieldComponentProps + ) => { + const { nodeId, field, fieldTemplate, settings } = props; + const { defaultValue, onChange, min, max, step, fineStep } = useFloatField( + nodeId, + field.name, + fieldTemplate, + settings + ); - return ( - - ); -}); + return ( + + ); + } +); FloatFieldSlider.displayName = 'FloatFieldSlider '; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField.ts index 15779bf0de..0540bbe187 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField.ts @@ -1,65 +1,93 @@ import { NUMPY_RAND_MAX } from 'app/constants'; import { useAppDispatch } from 'app/store/storeHooks'; -import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types'; +import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple'; import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice'; -import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field'; +import type { FloatFieldInputTemplate } from 'features/nodes/types/field'; +import { constrainNumber } from 'features/nodes/util/constrainNumber'; import { isNil } from 'lodash-es'; import { useCallback, useMemo } from 'react'; -export const useFloatField = (props: FieldComponentProps) => { - const { nodeId, field, fieldTemplate } = props; +export const useFloatField = ( + nodeId: string, + fieldName: string, + fieldTemplate: FloatFieldInputTemplate, + overrides: { min?: number; max?: number; step?: number } = {} +) => { + const { min: overrideMin, max: overrideMax, step: overrideStep } = overrides; const dispatch = useAppDispatch(); - const onChange = useCallback( - (value: number) => { - dispatch(fieldFloatValueChanged({ nodeId, fieldName: field.name, value })); - }, - [dispatch, field.name, nodeId] - ); - - const min = useMemo(() => { - let min = -NUMPY_RAND_MAX; - if (!isNil(fieldTemplate.minimum)) { - min = fieldTemplate.minimum; - } - if (!isNil(fieldTemplate.exclusiveMinimum)) { - min = fieldTemplate.exclusiveMinimum + 0.01; - } - return min; - }, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum]); - - const max = useMemo(() => { - let max = NUMPY_RAND_MAX; - if (!isNil(fieldTemplate.maximum)) { - max = fieldTemplate.maximum; - } - if (!isNil(fieldTemplate.exclusiveMaximum)) { - max = fieldTemplate.exclusiveMaximum - 0.01; - } - return max; - }, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum]); - const step = useMemo(() => { + if (overrideStep !== undefined) { + return overrideStep; + } if (isNil(fieldTemplate.multipleOf)) { return 0.1; } return fieldTemplate.multipleOf; - }, [fieldTemplate.multipleOf]); + }, [fieldTemplate.multipleOf, overrideStep]); const fineStep = useMemo(() => { + if (overrideStep !== undefined) { + return overrideStep; + } if (isNil(fieldTemplate.multipleOf)) { return 0.01; } return fieldTemplate.multipleOf; - }, [fieldTemplate.multipleOf]); + }, [fieldTemplate.multipleOf, overrideStep]); + + const min = useMemo(() => { + let min = -NUMPY_RAND_MAX; + + if (overrideMin !== undefined) { + min = overrideMin; + } else if (!isNil(fieldTemplate.minimum)) { + min = fieldTemplate.minimum; + } else if (!isNil(fieldTemplate.exclusiveMinimum)) { + min = fieldTemplate.exclusiveMinimum + 0.01; + } + + return roundUpToMultiple(min, step); + }, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum, overrideMin, step]); + + const max = useMemo(() => { + let max = NUMPY_RAND_MAX; + + if (overrideMax !== undefined) { + max = overrideMax; + } else if (!isNil(fieldTemplate.maximum)) { + max = fieldTemplate.maximum; + } else if (!isNil(fieldTemplate.exclusiveMaximum)) { + max = fieldTemplate.exclusiveMaximum - 0.01; + } + + return roundDownToMultiple(max, step); + }, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum, overrideMax, step]); + + const constrainValue = useCallback( + (v: number) => + constrainNumber( + v, + { min, max, multipleOf: step }, + { min: overrideMin, max: overrideMax, multipleOf: overrideStep } + ), + [max, min, overrideMax, overrideMin, overrideStep, step] + ); + + const onChange = useCallback( + (value: number) => { + dispatch(fieldFloatValueChanged({ nodeId, fieldName, value })); + }, + [dispatch, fieldName, nodeId] + ); return { defaultValue: fieldTemplate.default, onChange, - value: field.value, min, max, step, fineStep, + constrainValue, }; }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx index ae57b87c6e..131fffec67 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx @@ -95,6 +95,8 @@ import { } from 'features/nodes/types/field'; import type { NodeFieldElement } from 'features/nodes/types/workflow'; import { memo } from 'react'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; import BoardFieldInputComponent from './inputs/BoardFieldInputComponent'; import BooleanFieldInputComponent from './inputs/BooleanFieldInputComponent'; @@ -157,6 +159,8 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) return ; } else if (settings.component === 'textarea') { return ; + } else { + assert>(false, 'Unexpected settings.component'); } } @@ -171,32 +175,47 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) if (!isIntegerFieldInputInstance(field)) { return null; } - if (settings?.type !== 'integer-field-config') { + if (!settings || settings.type !== 'integer-field-config') { return ; } + if (settings.component === 'number-input') { return ; - } else if (settings.component === 'slider') { + } + + if (settings.component === 'slider') { return ; - } else if (settings.component === 'number-input-and-slider') { + } + + if (settings.component === 'number-input-and-slider') { return ; } + + assert>(false, 'Unexpected settings.component'); } if (isFloatFieldInputTemplate(template)) { if (!isFloatFieldInputInstance(field)) { return null; } - if (settings?.type !== 'float-field-config') { + + if (!settings || settings.type !== 'float-field-config') { return ; } + if (settings.component === 'number-input') { - return ; - } else if (settings.component === 'slider') { - return ; - } else if (settings.component === 'number-input-and-slider') { - return ; + return ; } + + if (settings.component === 'slider') { + return ; + } + + if (settings.component === 'number-input-and-slider') { + return ; + } + + assert>(false, 'Unexpected settings.component'); } if (isIntegerFieldCollectionInputTemplate(template)) { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput.tsx index 15a2e4c7ff..9ca2819bf3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput.tsx @@ -3,23 +3,37 @@ import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/I import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField'; import { NO_DRAG_CLASS } from 'features/nodes/types/constants'; import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field'; +import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow'; import { memo } from 'react'; export const IntegerFieldInput = memo( - (props: FieldComponentProps) => { - const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props); + ( + props: FieldComponentProps< + IntegerFieldInputInstance, + IntegerFieldInputTemplate, + { settings?: NodeFieldIntegerSettings } + > + ) => { + const { nodeId, field, fieldTemplate, settings } = props; + const { defaultValue, onChange, min, max, step, fineStep, constrainValue } = useIntegerField( + nodeId, + field.name, + fieldTemplate, + settings + ); return ( ); } diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx index 85a7a6a0b1..715f40d92d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx @@ -3,18 +3,31 @@ import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/I import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField'; import { NO_DRAG_CLASS } from 'features/nodes/types/constants'; import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field'; +import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow'; import { memo } from 'react'; export const IntegerFieldInputAndSlider = memo( - (props: FieldComponentProps) => { - const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props); + ( + props: FieldComponentProps< + IntegerFieldInputInstance, + IntegerFieldInputTemplate, + { settings?: NodeFieldIntegerSettings } + > + ) => { + const { nodeId, field, fieldTemplate, settings } = props; + const { defaultValue, onChange, min, max, step, fineStep } = useIntegerField( + nodeId, + field.name, + fieldTemplate, + settings + ); return ( <> ) => { - const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props); + ( + props: FieldComponentProps< + IntegerFieldInputInstance, + IntegerFieldInputTemplate, + { settings?: NodeFieldIntegerSettings } + > + ) => { + const { nodeId, field, fieldTemplate, settings } = props; + const { defaultValue, onChange, min, max, step, fineStep } = useIntegerField( + nodeId, + field.name, + fieldTemplate, + settings + ); return ( ) => { - const { nodeId, field, fieldTemplate } = props; +export const useIntegerField = ( + nodeId: string, + fieldName: string, + fieldTemplate: IntegerFieldInputTemplate, + overrides: { min?: number; max?: number; step?: number } = {} +) => { + const { min: overrideMin, max: overrideMax, step: overrideStep } = overrides; const dispatch = useAppDispatch(); - const onChange = useCallback( - (value: number) => { - dispatch(fieldIntegerValueChanged({ nodeId, fieldName: field.name, value: Math.floor(Number(value)) })); - }, - [dispatch, field.name, nodeId] - ); + const step = useMemo(() => { + if (overrideStep !== undefined) { + return overrideStep; + } + if (isNil(fieldTemplate.multipleOf)) { + return 1; + } + return fieldTemplate.multipleOf; + }, [fieldTemplate.multipleOf, overrideStep]); + + const fineStep = useMemo(() => { + if (overrideStep !== undefined) { + return overrideStep; + } + if (isNil(fieldTemplate.multipleOf)) { + return 1; + } + return fieldTemplate.multipleOf; + }, [fieldTemplate.multipleOf, overrideStep]); const min = useMemo(() => { let min = -NUMPY_RAND_MAX; - if (!isNil(fieldTemplate.minimum)) { + + if (overrideMin !== undefined) { + min = overrideMin; + } else if (!isNil(fieldTemplate.minimum)) { min = fieldTemplate.minimum; - } - if (!isNil(fieldTemplate.exclusiveMinimum)) { + } else if (!isNil(fieldTemplate.exclusiveMinimum)) { min = fieldTemplate.exclusiveMinimum + 1; } - return min; - }, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum]); + + return roundUpToMultiple(min, step); + }, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum, overrideMin, step]); const max = useMemo(() => { let max = NUMPY_RAND_MAX; - if (!isNil(fieldTemplate.maximum)) { + + if (overrideMax !== undefined) { + max = overrideMax; + } else if (!isNil(fieldTemplate.maximum)) { max = fieldTemplate.maximum; - } - if (!isNil(fieldTemplate.exclusiveMaximum)) { + } else if (!isNil(fieldTemplate.exclusiveMaximum)) { max = fieldTemplate.exclusiveMaximum - 1; } - return max; - }, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum]); - const step = useMemo(() => { - if (isNil(fieldTemplate.multipleOf)) { - return 1; - } - return fieldTemplate.multipleOf; - }, [fieldTemplate.multipleOf]); + return roundDownToMultiple(max, step); + }, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum, overrideMax, step]); - const fineStep = useMemo(() => { - if (isNil(fieldTemplate.multipleOf)) { - return 1; - } - return fieldTemplate.multipleOf; - }, [fieldTemplate.multipleOf]); + const constrainValue = useCallback( + (v: number) => + constrainNumber( + v, + { min, max, multipleOf: step }, + { min: overrideMin, max: overrideMax, multipleOf: overrideStep } + ), + [max, min, overrideMax, overrideMin, overrideStep, step] + ); + + const onChange = useCallback( + (value: number) => { + dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value })); + }, + [dispatch, fieldName, nodeId] + ); return { defaultValue: fieldTemplate.default, onChange, - value: field.value, min, max, step, fineStep, + constrainValue, }; }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/types.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/types.ts index 7ebb019e6c..d495c882d9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/types.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/types.ts @@ -1,7 +1,11 @@ import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field'; -export type FieldComponentProps = { +export type FieldComponentProps< + TFieldInstance extends FieldInputInstance, + TFieldTemplate extends FieldInputTemplate, + FieldSettings = void, +> = { nodeId: string; - field: V; - fieldTemplate: T; -} & Omit; + field: TFieldInstance; + fieldTemplate: TFieldTemplate; +} & Omit; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings.tsx index a5b7657dd6..01c54416ff 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings.tsx @@ -1,12 +1,35 @@ -import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; +import { CompositeNumberInput, Flex, FormControl, FormLabel, Select, Switch } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; +import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField'; +import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance'; +import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice'; import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice'; +import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field'; import { type NodeFieldFloatSettings, zNumberComponent } from 'features/nodes/types/workflow'; import type { ChangeEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -export const NodeFieldElementFloatSettings = memo(({ id, config }: { id: string; config: NodeFieldFloatSettings }) => { +type Props = { + id: string; + config: NodeFieldFloatSettings; + nodeId: string; + fieldName: string; + fieldTemplate: FloatFieldInputTemplate; +}; + +export const NodeFieldElementFloatSettings = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => { + return ( + <> + + + + + ); +}); +NodeFieldElementFloatSettings.displayName = 'NodeFieldElementFloatSettings'; + +const SettingComponent = memo(({ id, config }: Props) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -22,7 +45,7 @@ export const NodeFieldElementFloatSettings = memo(({ id, config }: { id: string; ); return ( - + {t('workflows.builder.component')} - - - - - - ); - } -); -NodeFieldElementIntegerConfig.displayName = 'NodeFieldElementIntegerConfig'; +const SettingComponent = memo(({ id, config }: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const onChangeComponent = useCallback( + (e: ChangeEvent) => { + const newConfig: NodeFieldIntegerSettings = { + ...config, + component: zNumberComponent.parse(e.target.value), + }; + dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } })); + }, + [config, dispatch, id] + ); + + return ( + + {t('workflows.builder.component')} + + + ); +}); +SettingComponent.displayName = 'SettingComponent'; + +const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const field = useInputFieldInstance(nodeId, fieldName); + + const floatField = useIntegerField(nodeId, fieldName, fieldTemplate); + + const onToggleSetting = useCallback(() => { + const newConfig: NodeFieldIntegerSettings = { + ...config, + min: config.min !== undefined ? undefined : floatField.min, + }; + dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } })); + }, [config, dispatch, floatField.min, id]); + + const onChange = useCallback( + (v: number) => { + const newConfig: NodeFieldIntegerSettings = { + ...config, + min: v, + }; + dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } })); + const constrained = constrain(v, floatField, newConfig); + if (field.value !== constrained) { + dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: v })); + } + }, + [config, dispatch, field.value, fieldName, floatField, id, nodeId] + ); + + return ( + + + {t('workflows.builder.minimum')} + + + + + ); +}); +SettingMin.displayName = 'SettingMin'; + +const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const field = useInputFieldInstance(nodeId, fieldName); + + const floatField = useIntegerField(nodeId, fieldName, fieldTemplate); + + const onToggleSetting = useCallback(() => { + const newConfig: NodeFieldIntegerSettings = { + ...config, + max: config.max !== undefined ? undefined : floatField.max, + }; + dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } })); + }, [config, dispatch, floatField.max, id]); + + const onChange = useCallback( + (v: number) => { + const newConfig: NodeFieldIntegerSettings = { + ...config, + max: v, + }; + dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } })); + const constrained = constrain(v, floatField, newConfig); + if (field.value !== constrained) { + dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: v })); + } + }, + [config, dispatch, field.value, fieldName, floatField, id, nodeId] + ); + + return ( + + + {t('workflows.builder.maximum')} + + + + + ); +}); +SettingMax.displayName = 'SettingMax'; + +type NumberConstraints = { min: number; max: number; step: number }; + +const constrain = (v: number, constraints: NumberConstraints, overrides: PartialDeep) => { + const min = overrides.min ?? constraints.min; + const max = overrides.max ?? constraints.max; + const step = overrides.step ?? constraints.step; + + console.log({ min, max, step }); + + const _v = Math.min(max, Math.max(min, v)); + const _diff = _v - min; + const _steps = Math.round(_diff / step); + return min + _steps * step; +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx index 7a2cff013b..1ae99862c4 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx @@ -1,4 +1,5 @@ import { + Flex, FormControl, FormLabel, IconButton, @@ -12,7 +13,7 @@ import { } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { NodeFieldElementFloatSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings'; -import { NodeFieldElementIntegerConfig } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings'; +import { NodeFieldElementIntegerSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings'; import { NodeFieldElementStringSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringSettings'; import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate'; import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice'; @@ -70,13 +71,31 @@ export const NodeFieldElementSettings = memo(({ element }: { element: NodeFieldE - - {t('workflows.builder.showDescription')} - - - {settings?.type === 'integer-field-config' && } - {settings?.type === 'float-field-config' && } - {settings?.type === 'string-field-config' && } + + + {t('workflows.builder.showDescription')} + + + {settings?.type === 'integer-field-config' && isIntegerFieldInputTemplate(fieldTemplate) && ( + + )} + {settings?.type === 'float-field-config' && isFloatFieldInputTemplate(fieldTemplate) && ( + + )} + {settings?.type === 'string-field-config' && } + diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/_NodeFieldElementIntegerSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/_NodeFieldElementIntegerSettings.tsx new file mode 100644 index 0000000000..8992aef19e --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/_NodeFieldElementIntegerSettings.tsx @@ -0,0 +1,37 @@ +import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice'; +import { type NodeFieldIntegerSettings, zNumberComponent } from 'features/nodes/types/workflow'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const NodeFieldElementIntegerConfig = memo( + ({ id, config }: { id: string; config: NodeFieldIntegerSettings }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const onChangeComponent = useCallback( + (e: ChangeEvent) => { + const newConfig: NodeFieldIntegerSettings = { + ...config, + component: zNumberComponent.parse(e.target.value), + }; + dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } })); + }, + [config, dispatch, id] + ); + + return ( + + {t('workflows.builder.component')} + + + ); + } +); +NodeFieldElementIntegerConfig.displayName = 'NodeFieldElementIntegerConfig'; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldInstance.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldInstance.ts index ee1f94a30f..4650b68857 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldInstance.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldInstance.ts @@ -5,7 +5,7 @@ import type { FieldInputInstance } from 'features/nodes/types/field'; import { useMemo } from 'react'; import { assert } from 'tsafe'; -export const useInputFieldInstance = (nodeId: string, fieldName: string): FieldInputInstance => { +export const useInputFieldInstance = (nodeId: string, fieldName: string): T => { const selector = useMemo( () => createSelector(selectNodesSlice, (nodes) => { @@ -18,5 +18,5 @@ export const useInputFieldInstance = (nodeId: string, fieldName: string): FieldI const instance = useAppSelector(selector); - return instance; + return instance as T; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts index 94fa97ecab..f238b14912 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/fieldValidators.ts @@ -4,20 +4,29 @@ import type { FieldInputTemplate, FloatFieldCollectionInputTemplate, FloatFieldCollectionValue, + FloatFieldInputTemplate, + FloatFieldValue, ImageFieldCollectionInputTemplate, ImageFieldCollectionValue, IntegerFieldCollectionInputTemplate, IntegerFieldCollectionValue, + IntegerFieldInputTemplate, + IntegerFieldValue, + StatefulFieldValue, StringFieldCollectionInputTemplate, StringFieldCollectionValue, } from 'features/nodes/types/field'; import { isFloatFieldCollectionInputInstance, isFloatFieldCollectionInputTemplate, + isFloatFieldInputInstance, + isFloatFieldInputTemplate, isImageFieldCollectionInputInstance, isImageFieldCollectionInputTemplate, isIntegerFieldCollectionInputInstance, isIntegerFieldCollectionInputTemplate, + isIntegerFieldInputInstance, + isIntegerFieldInputTemplate, isStringFieldCollectionInputInstance, isStringFieldCollectionInputTemplate, } from 'features/nodes/types/field'; @@ -25,10 +34,15 @@ import { type InvocationNode, type InvocationTemplate, isInvocationNode } from ' import { t } from 'i18next'; import { assert } from 'tsafe'; -const validateImageFieldCollectionValue = ( - value: NonNullable, - template: ImageFieldCollectionInputTemplate -): string[] => { +type FieldValidationFunc = ( + value: TValue, + template: TTemplate +) => string[]; + +const validateImageFieldCollectionValue: FieldValidationFunc< + NonNullable, + ImageFieldCollectionInputTemplate +> = (value, template) => { const reasons: string[] = []; const { minItems, maxItems } = template; const count = value.length; @@ -49,10 +63,10 @@ const validateImageFieldCollectionValue = ( return reasons; }; -const validateStringFieldCollectionValue = ( - value: NonNullable, - template: StringFieldCollectionInputTemplate -): string[] => { +const validateStringFieldCollectionValue: FieldValidationFunc< + NonNullable, + StringFieldCollectionInputTemplate +> = (value, template) => { const reasons: string[] = []; const { minItems, maxItems, minLength, maxLength } = template; const count = value.length; @@ -82,10 +96,10 @@ const validateStringFieldCollectionValue = ( return reasons; }; -const validateNumberFieldCollectionValue = ( - value: NonNullable | NonNullable, - template: IntegerFieldCollectionInputTemplate | FloatFieldCollectionInputTemplate -): string[] => { +const validateNumberFieldCollectionValue: FieldValidationFunc< + NonNullable | NonNullable, + IntegerFieldCollectionInputTemplate | FloatFieldCollectionInputTemplate +> = (value, template) => { const reasons: string[] = []; const { minItems, maxItems, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = template; const count = value.length; @@ -124,6 +138,32 @@ const validateNumberFieldCollectionValue = ( return reasons; }; +const validateNumberFieldValue: FieldValidationFunc< + FloatFieldValue | IntegerFieldValue, + FloatFieldInputTemplate | IntegerFieldInputTemplate +> = (value, template) => { + const reasons: string[] = []; + const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = template; + + if (maximum !== undefined && value > maximum) { + reasons.push(t('parameters.invoke.collectionNumberGTMax', { value, maximum })); + } + if (minimum !== undefined && value < minimum) { + reasons.push(t('parameters.invoke.collectionNumberLTMin', { value, minimum })); + } + if (exclusiveMaximum !== undefined && value >= exclusiveMaximum) { + reasons.push(t('parameters.invoke.collectionNumberGTExclusiveMax', { value, exclusiveMaximum })); + } + if (exclusiveMinimum !== undefined && value <= exclusiveMinimum) { + reasons.push(t('parameters.invoke.collectionNumberLTExclusiveMin', { value, exclusiveMinimum })); + } + if (multipleOf !== undefined && value % multipleOf !== 0) { + reasons.push(t('parameters.invoke.collectionNumberNotMultipleOf', { value, multipleOf })); + } + + return reasons; +}; + type NodeError = { type: 'node-error'; nodeId: string; @@ -147,6 +187,16 @@ const getFieldErrorPrefix = ( return `${node.data.label || nodeTemplate.title} -> ${field.label || fieldTemplate.title}`; }; +const getIssuesToFieldErrorsMapFunc = + (nodeId: string, fieldName: string, prefix: string): ((issue: string) => FieldError) => + (issue: string) => ({ + type: 'field-error', + nodeId, + fieldName, + prefix, + issue, + }); + export const getFieldErrors = ( node: InvocationNode, nodeTemplate: InvocationTemplate, @@ -156,6 +206,7 @@ export const getFieldErrors = ( ): FieldError[] => { const errors: FieldError[] = []; const prefix = getFieldErrorPrefix(node, nodeTemplate, field, fieldTemplate); + const issueToFieldError = getIssuesToFieldErrorsMapFunc(node.data.id, field.name, prefix); const nodeId = node.data.id; const fieldName = field.name; @@ -176,68 +227,21 @@ export const getFieldErrors = ( }); } else if (isConnected) { // Connected fields have no value to validate - they are OK - } else if ( - field.value && - isImageFieldCollectionInputTemplate(fieldTemplate) && - isImageFieldCollectionInputInstance(field) - ) { - const issues = validateImageFieldCollectionValue(field.value, fieldTemplate); - errors.push( - ...issues.map((issue) => ({ - type: 'field-error', - nodeId, - fieldName, - prefix, - issue, - })) - ); - } else if ( - field.value && - isStringFieldCollectionInputTemplate(fieldTemplate) && - isStringFieldCollectionInputInstance(field) - ) { - const issues = validateStringFieldCollectionValue(field.value, fieldTemplate); - errors.push( - ...issues.map((issue) => ({ - type: 'field-error', - nodeId, - fieldName, - prefix, - issue, - })) - ); - } else if ( - field.value && - isIntegerFieldCollectionInputTemplate(fieldTemplate) && - isIntegerFieldCollectionInputInstance(field) - ) { - const issues = validateNumberFieldCollectionValue(field.value, fieldTemplate); - errors.push( - ...issues.map((issue) => ({ - type: 'field-error', - nodeId, - fieldName, - prefix, - issue, - })) - ); - } else if ( - field.value && - isFloatFieldCollectionInputTemplate(fieldTemplate) && - isFloatFieldCollectionInputInstance(field) - ) { - const issues = validateNumberFieldCollectionValue(field.value, fieldTemplate); - errors.push( - ...issues.map((issue) => ({ - type: 'field-error', - nodeId, - fieldName, - prefix, - issue, - })) - ); + } else if (field.value !== undefined) { + if (isImageFieldCollectionInputTemplate(fieldTemplate) && isImageFieldCollectionInputInstance(field)) { + errors.push(...validateImageFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError)); + } else if (isStringFieldCollectionInputTemplate(fieldTemplate) && isStringFieldCollectionInputInstance(field)) { + errors.push(...validateStringFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError)); + } else if (isIntegerFieldCollectionInputTemplate(fieldTemplate) && isIntegerFieldCollectionInputInstance(field)) { + errors.push(...validateNumberFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError)); + } else if (isFloatFieldCollectionInputTemplate(fieldTemplate) && isFloatFieldCollectionInputInstance(field)) { + errors.push(...validateNumberFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError)); + } else if (isFloatFieldInputTemplate(fieldTemplate) && isFloatFieldInputInstance(field)) { + errors.push(...validateNumberFieldValue(field.value, fieldTemplate).map(issueToFieldError)); + } else if (isIntegerFieldInputTemplate(fieldTemplate) && isIntegerFieldInputInstance(field)) { + errors.push(...validateNumberFieldValue(field.value, fieldTemplate).map(issueToFieldError)); + } } - return errors; }; diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts index c70df57cde..7f1e8c3613 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts @@ -75,6 +75,9 @@ const FLOAT_FIELD_SETTINGS_TYPE = 'float-field-config'; const zNodeFieldFloatSettings = z.object({ type: z.literal(FLOAT_FIELD_SETTINGS_TYPE).default(FLOAT_FIELD_SETTINGS_TYPE), component: zNumberComponent.default('number-input'), + min: z.number().optional(), + max: z.number().optional(), + step: z.number().optional(), }); export const getFloatFieldSettingsDefaults = (): NodeFieldFloatSettings => zNodeFieldFloatSettings.parse({}); export type NodeFieldFloatSettings = z.infer; @@ -83,6 +86,9 @@ const INTEGER_FIELD_CONFIG_TYPE = 'integer-field-config'; const zNodeFieldIntegerSettings = z.object({ type: z.literal(INTEGER_FIELD_CONFIG_TYPE).default(INTEGER_FIELD_CONFIG_TYPE), component: zNumberComponent.default('number-input'), + min: z.number().optional(), + max: z.number().optional(), + step: z.number().optional(), }); export type NodeFieldIntegerSettings = z.infer; export const getIntegerFieldSettingsDefaults = (): NodeFieldIntegerSettings => zNodeFieldIntegerSettings.parse({}); diff --git a/invokeai/frontend/web/src/features/nodes/util/constrainNumber.test.ts b/invokeai/frontend/web/src/features/nodes/util/constrainNumber.test.ts new file mode 100644 index 0000000000..fd1ea1f606 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/constrainNumber.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { constrainNumber } from './constrainNumber'; + +describe('constrainNumber', () => { + // Default constraints to be used in tests + const defaultConstraints = { min: 0, max: 10, multipleOf: 1 }; + + it('should keep values within range', () => { + expect(constrainNumber(5, defaultConstraints)).toBe(5); + expect(constrainNumber(-5, defaultConstraints)).toBe(0); + expect(constrainNumber(15, defaultConstraints)).toBe(10); + }); + + it('should round to nearest multiple', () => { + const constraints = { min: 0, max: 10, multipleOf: 2 }; + expect(constrainNumber(1, constraints)).toBe(2); + expect(constrainNumber(2, constraints)).toBe(2); + expect(constrainNumber(3, constraints)).toBe(4); + expect(constrainNumber(9, constraints)).toBe(10); + expect(constrainNumber(11, constraints)).toBe(10); + }); + + it('should handle negative multiples', () => { + const constraints = { min: -10, max: 10, multipleOf: 3 }; + expect(constrainNumber(-9, constraints)).toBe(-9); + expect(constrainNumber(-8, constraints)).toBe(-9); + expect(constrainNumber(-7, constraints)).toBe(-6); + expect(constrainNumber(-3, constraints)).toBe(-3); + expect(constrainNumber(-2, constraints)).toBe(-3); + // In JS, -0 !== +0... :) + expect(constrainNumber(-1, constraints)).toBe(0); + expect(constrainNumber(0, constraints)).toBe(0); + expect(constrainNumber(1, constraints)).toBe(0); + expect(constrainNumber(2, constraints)).toBe(3); + expect(constrainNumber(3, constraints)).toBe(3); + expect(constrainNumber(4, constraints)).toBe(3); + expect(constrainNumber(7, constraints)).toBe(6); + expect(constrainNumber(8, constraints)).toBe(9); + expect(constrainNumber(9, constraints)).toBe(9); + }); + + it('should respect boundaries when rounding', () => { + const constraints = { min: 0, max: 10, multipleOf: 4 }; + // Value at 9 would normally round to 8 + expect(constrainNumber(9, constraints)).toBe(8); + // Value at 11 would normally round to 12, but max is 10 + expect(constrainNumber(11, constraints)).toBe(10); + }); + + it('should handle decimal multiples', () => { + const constraints = { min: 0, max: 1, multipleOf: 0.25 }; + expect(constrainNumber(0.3, constraints)).toBe(0.25); + expect(constrainNumber(0.87, constraints)).toBe(0.75); + expect(constrainNumber(0.88, constraints)).toBe(1.0); + expect(constrainNumber(0.13, constraints)).toBe(0.25); + }); + + it('should apply overrides correctly', () => { + // Override min + expect(constrainNumber(2, defaultConstraints, { min: 5 })).toBe(5); + + // Override max + expect(constrainNumber(8, defaultConstraints, { max: 7 })).toBe(7); + + // Override multipleOf + expect(constrainNumber(4.7, defaultConstraints, { multipleOf: 2 })).toBe(4); + + // Override all + expect(constrainNumber(15, defaultConstraints, { min: 5, max: 20, multipleOf: 5 })).toBe(15); + }); + + it('should handle edge cases', () => { + // Value exactly at min + expect(constrainNumber(0, defaultConstraints)).toBe(0); + + // Value exactly at max + expect(constrainNumber(10, defaultConstraints)).toBe(10); + + // multipleOf larger than range + const narrowConstraints = { min: 5, max: 7, multipleOf: 5 }; + expect(constrainNumber(6, narrowConstraints)).toBe(5); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/constrainNumber.ts b/invokeai/frontend/web/src/features/nodes/util/constrainNumber.ts new file mode 100644 index 0000000000..aa18215087 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/constrainNumber.ts @@ -0,0 +1,33 @@ +import type { PartialDeep } from 'type-fest'; + +type NumberConstraints = { min: number; max: number; multipleOf?: number }; + +/** + * Constrain a number to a range and round to the nearest multiple of a given value. + * @param v + * @param constraints + * @param overrides + * @returns + */ +export const constrainNumber = ( + v: number, + constraints: NumberConstraints, + overrides?: PartialDeep +) => { + const min = overrides?.min ?? constraints.min; + const max = overrides?.max ?? constraints.max; + const multipleOf = overrides?.multipleOf ?? constraints.multipleOf; + + if (multipleOf === undefined) { + return Math.min(Math.max(v, min), max); + } + + // First clamp to range + v = Math.min(Math.max(v, min), max); + + // Round to nearest multiple of multipleOf + const roundedValue = Math.round(v / multipleOf) * multipleOf; + + // Ensure the result is still within the range + return Math.min(Math.max(roundedValue, min), max); +};