diff --git a/invokeai/backend/model_manager/configs/lora.py b/invokeai/backend/model_manager/configs/lora.py index 46606a3c0d5..394085be151 100644 --- a/invokeai/backend/model_manager/configs/lora.py +++ b/invokeai/backend/model_manager/configs/lora.py @@ -6,7 +6,7 @@ Self, ) -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from invokeai.backend.model_manager.configs.base import ( Config_Base, @@ -38,10 +38,35 @@ from invokeai.backend.patches.lora_conversions.flux_control_lora_utils import is_state_dict_likely_flux_control +# Defaults used to compute the effective slider range when one or both bounds +# are unset. These intentionally mirror the frontend's DEFAULT_LORA_WEIGHT_CONFIG +# in invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts so that +# bound/weight validation produces the same result whether it runs in the form +# or in this pydantic model. +_DEFAULT_LORA_WEIGHT_SLIDER_MIN = -1.0 +_DEFAULT_LORA_WEIGHT_SLIDER_MAX = 2.0 + + class LoraModelDefaultSettings(BaseModel): - weight: float | None = Field(default=None, ge=-1, le=2, description="Default weight for this model") + weight: float | None = Field(default=None, description="Default weight for this model") + weight_min: float | None = Field(default=None, description="Minimum weight slider value for this model") + weight_max: float | None = Field(default=None, description="Maximum weight slider value for this model") model_config = ConfigDict(extra="forbid") + @model_validator(mode="after") + def _validate_weight_bounds(self) -> "LoraModelDefaultSettings": + effective_min = self.weight_min if self.weight_min is not None else _DEFAULT_LORA_WEIGHT_SLIDER_MIN + effective_max = self.weight_max if self.weight_max is not None else _DEFAULT_LORA_WEIGHT_SLIDER_MAX + if effective_min >= effective_max: + raise ValueError( + f"effective weight range is invalid: min ({effective_min}) must be less than max ({effective_max})" + ) + if self.weight is not None and not (effective_min <= self.weight <= effective_max): + raise ValueError( + f"weight ({self.weight}) must be within the effective range [{effective_min}, {effective_max}]" + ) + return self + class LoRA_Config_Base(ABC, BaseModel): """Base class for LoRA models.""" diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c164d1dafe1..c0e809661fd 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1004,6 +1004,11 @@ }, "lora": { "weight": "Weight", + "startingWeight": "Starting Weight", + "weightMin": "Min Weight", + "weightMax": "Max Weight", + "weightMinMustBeLessThanMax": "Min weight must be less than max weight", + "weightOutOfRange": "Starting weight must be within the min/max range", "removeLoRA": "Remove LoRA" }, "metadata": { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index cbeccdfa930..ccb9f652374 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -687,7 +687,7 @@ export const zLoRA = z.object({ id: z.string(), isEnabled: z.boolean(), model: zModelIdentifierField, - weight: z.number().gte(-10).lte(10), + weight: z.number(), }); export type LoRA = z.infer; diff --git a/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx b/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx index 87dc8c2a190..8b118a3a12c 100644 --- a/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx +++ b/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx @@ -23,8 +23,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; import { useGetModelConfigQuery } from 'services/api/endpoints/models'; - -const MARKS = [-1, 0, 1, 2]; +import { isLoRAModelConfig } from 'services/api/types'; export const LoRACard = memo((props: { id: string }) => { const selectLoRA = useMemo(() => buildSelectLoRA(props.id), [props.id]); @@ -58,6 +57,20 @@ const LoRAContent = memo(({ lora }: { lora: LoRA }) => { dispatch(loraDeleted({ id: lora.id })); }, [dispatch, lora.id]); + const loraDefaults = loraConfig && isLoRAModelConfig(loraConfig) ? loraConfig.default_settings : null; + const sliderMin = loraDefaults?.weight_min ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMin; + const sliderMax = loraDefaults?.weight_max ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMax; + const numberInputMin = Math.min(sliderMin, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMin); + const numberInputMax = Math.max(sliderMax, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMax); + + const marks = useMemo(() => { + if (sliderMin >= sliderMax) { + return [sliderMin, sliderMax]; + } + const mid = (sliderMin + sliderMax) / 2; + return [sliderMin, mid, sliderMax]; + }, [sliderMin, sliderMax]); + return ( @@ -82,19 +95,19 @@ const LoRAContent = memo(({ lora }: { lora: LoRA }) => { { isEnabled: !isNil(modelConfig?.default_settings?.weight), value: modelConfig?.default_settings?.weight ?? DEFAULT_LORA_WEIGHT_CONFIG.initial, }, + weightMin: { + isEnabled: !isNil(modelConfig?.default_settings?.weight_min), + value: modelConfig?.default_settings?.weight_min ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMin, + }, + weightMax: { + isEnabled: !isNil(modelConfig?.default_settings?.weight_max), + value: modelConfig?.default_settings?.weight_max ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMax, + }, }; }, [modelConfig?.default_settings]); diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx index c26ce38c671..d5941bff3de 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx @@ -1,22 +1,26 @@ -import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { DEFAULT_LORA_WEIGHT_CONFIG } from 'features/controlLayers/store/lorasSlice'; import { SettingToggle } from 'features/modelManagerV2/subpanels/ModelPanel/SettingToggle'; import { memo, useCallback, useMemo } from 'react'; import type { UseControllerProps } from 'react-hook-form'; -import { useController } from 'react-hook-form'; +import { useController, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { LoRAModelDefaultSettingsFormData } from './LoRAModelDefaultSettings'; -const MARKS = [-1, 0, 1, 2]; - -type DefaultWeight = LoRAModelDefaultSettingsFormData['weight']; - export const DefaultWeight = memo((props: UseControllerProps) => { const { field } = useController(props); const { t } = useTranslation(); + const weightMin = useWatch({ control: props.control, name: 'weightMin' }); + const weightMax = useWatch({ control: props.control, name: 'weightMax' }); + + const sliderMin = weightMin?.isEnabled ? weightMin.value : DEFAULT_LORA_WEIGHT_CONFIG.sliderMin; + const sliderMax = weightMax?.isEnabled ? weightMax.value : DEFAULT_LORA_WEIGHT_CONFIG.sliderMax; + const numberInputMin = Math.min(sliderMin, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMin); + const numberInputMax = Math.max(sliderMax, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMax); + const onChange = useCallback( (v: number) => { const updatedValue = { @@ -40,26 +44,16 @@ export const DefaultWeight = memo((props: UseControllerProps - {t('lora.weight')} + {t('lora.startingWeight')} - ) => { + const { field } = useController(props); + const { t } = useTranslation(); + + const onChange = useCallback( + (v: number) => { + field.onChange({ ...field.value, value: v }); + }, + [field] + ); + + const value = useMemo(() => field.value.value, [field.value]); + const isDisabled = useMemo(() => !field.value.isEnabled, [field.value]); + + return ( + + + {t('lora.weightMax')} + + + + + + + ); +}); + +DefaultWeightMax.displayName = 'DefaultWeightMax'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin.tsx new file mode 100644 index 00000000000..f37d2554b51 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin.tsx @@ -0,0 +1,46 @@ +import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { DEFAULT_LORA_WEIGHT_CONFIG } from 'features/controlLayers/store/lorasSlice'; +import { SettingToggle } from 'features/modelManagerV2/subpanels/ModelPanel/SettingToggle'; +import { memo, useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { LoRAModelDefaultSettingsFormData } from './LoRAModelDefaultSettings'; + +export const DefaultWeightMin = memo((props: UseControllerProps) => { + const { field } = useController(props); + const { t } = useTranslation(); + + const onChange = useCallback( + (v: number) => { + field.onChange({ ...field.value, value: v }); + }, + [field] + ); + + const value = useMemo(() => field.value.value, [field.value]); + const isDisabled = useMemo(() => !field.value.isEnabled, [field.value]); + + return ( + + + {t('lora.weightMin')} + + + + + + + ); +}); + +DefaultWeightMin.displayName = 'DefaultWeightMin'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx index 2f509caa726..cf792cce325 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx @@ -1,7 +1,10 @@ import { Button, Flex, Heading, SimpleGrid } from '@invoke-ai/ui-library'; +import { DEFAULT_LORA_WEIGHT_CONFIG } from 'features/controlLayers/store/lorasSlice'; import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { useLoRAModelDefaultSettings } from 'features/modelManagerV2/hooks/useLoRAModelDefaultSettings'; import { DefaultWeight } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight'; +import { DefaultWeightMax } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMax'; +import { DefaultWeightMin } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin'; import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings'; import { toast } from 'features/toast/toast'; import { memo, useCallback, useEffect } from 'react'; @@ -14,6 +17,8 @@ import type { LoRAModelConfig } from 'services/api/types'; export type LoRAModelDefaultSettingsFormData = { weight: FormField; + weightMin: FormField; + weightMax: FormField; }; type Props = { @@ -28,7 +33,7 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { const [updateModel, { isLoading: isLoadingUpdateModel }] = useUpdateModelMutation(); - const { handleSubmit, control, formState, reset } = useForm({ + const { handleSubmit, control, formState, reset, setError } = useForm({ defaultValues: defaultSettingsDefaults, }); @@ -38,8 +43,40 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { const onSubmit = useCallback>( (data) => { + const weightMin = data.weightMin.isEnabled ? data.weightMin.value : null; + const weightMax = data.weightMax.isEnabled ? data.weightMax.value : null; + const weight = data.weight.isEnabled ? data.weight.value : null; + + // Compute effective bounds the same way LoRACard does when rendering the slider, + // so partial bounds (only min or only max) get validated against the fallback + // default for the other side. + const effectiveMin = weightMin ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMin; + const effectiveMax = weightMax ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMax; + + if (effectiveMin >= effectiveMax) { + setError('weightMin', { type: 'manual', message: t('lora.weightMinMustBeLessThanMax') }); + toast({ + id: 'DEFAULT_SETTINGS_SAVE_FAILED', + title: t('lora.weightMinMustBeLessThanMax'), + status: 'error', + }); + return; + } + + if (weight !== null && (weight < effectiveMin || weight > effectiveMax)) { + setError('weight', { type: 'manual', message: t('lora.weightOutOfRange') }); + toast({ + id: 'DEFAULT_SETTINGS_SAVE_FAILED', + title: t('lora.weightOutOfRange'), + status: 'error', + }); + return; + } + const body = { - weight: data.weight.isEnabled ? data.weight.value : null, + weight, + weight_min: weightMin, + weight_max: weightMax, }; updateModel({ @@ -65,7 +102,7 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { } }); }, - [updateModel, modelConfig.key, t, reset] + [updateModel, modelConfig.key, t, reset, setError] ); return ( @@ -86,8 +123,10 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { )} - + + + ); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 68f24a26ec1..0e79b3c688e 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -19346,6 +19346,16 @@ export type components = { * @description Default weight for this model */ weight?: number | null; + /** + * Weight Min + * @description Minimum weight slider value for this model + */ + weight_min?: number | null; + /** + * Weight Max + * @description Maximum weight slider value for this model + */ + weight_max?: number | null; }; /** MDControlListOutput */ MDControlListOutput: { diff --git a/tests/backend/model_manager/configs/test_lora_default_settings.py b/tests/backend/model_manager/configs/test_lora_default_settings.py new file mode 100644 index 00000000000..2e80db90ad1 --- /dev/null +++ b/tests/backend/model_manager/configs/test_lora_default_settings.py @@ -0,0 +1,112 @@ +import pytest +from pydantic import ValidationError + +from invokeai.backend.model_manager.configs.lora import LoraModelDefaultSettings + + +def test_accepts_none_for_all_fields() -> None: + settings = LoraModelDefaultSettings() + assert settings.weight is None + assert settings.weight_min is None + assert settings.weight_max is None + + +def test_accepts_both_bounds_with_min_less_than_max() -> None: + settings = LoraModelDefaultSettings(weight_min=-2.0, weight_max=3.0) + assert settings.weight_min == -2.0 + assert settings.weight_max == 3.0 + + +def test_rejects_both_bounds_with_min_greater_than_or_equal_to_max() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=2.0, weight_max=2.0) + + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=3.0, weight_max=1.0) + + +def test_accepts_only_weight_min_within_default_range() -> None: + # Default max is 2.0; a weight_min of 1.0 leaves a valid effective range [1.0, 2.0]. + settings = LoraModelDefaultSettings(weight_min=1.0) + assert settings.weight_min == 1.0 + assert settings.weight_max is None + + +def test_rejects_only_weight_min_above_default_max() -> None: + # Reproduces the partial-bound bug: saving only weight_min=3 used to be accepted but + # rendered as a min>max slider in LoRACard. The effective max defaults to 2.0. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=3.0) + + +def test_rejects_only_weight_min_equal_to_default_max() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=2.0) + + +def test_accepts_only_weight_max_within_default_range() -> None: + # Default min is -1.0; a weight_max of 0.5 leaves a valid effective range [-1.0, 0.5]. + settings = LoraModelDefaultSettings(weight_max=0.5) + assert settings.weight_max == 0.5 + assert settings.weight_min is None + + +def test_rejects_only_weight_max_below_default_min() -> None: + # Reproduces the symmetric partial-bound bug for weight_max <= -1. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_max=-2.0) + + +def test_rejects_only_weight_max_equal_to_default_min() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_max=-1.0) + + +def test_accepts_weight_within_effective_range_with_explicit_bounds() -> None: + settings = LoraModelDefaultSettings(weight=0.5, weight_min=-1.0, weight_max=2.0) + assert settings.weight == 0.5 + + +def test_accepts_weight_within_default_effective_range() -> None: + # With no bounds set the effective range is [-1.0, 2.0]. + settings = LoraModelDefaultSettings(weight=1.5) + assert settings.weight == 1.5 + + +def test_rejects_weight_above_explicit_effective_range() -> None: + # Reproduces the out-of-range-weight bug: weight=10 with bounds [-1, 2] used to be + # accepted but the slider in LoRACard could only show [-1, 2]. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=10.0, weight_min=-1.0, weight_max=2.0) + + +def test_rejects_weight_below_explicit_effective_range() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=-5.0, weight_min=-1.0, weight_max=2.0) + + +def test_rejects_weight_above_default_effective_range() -> None: + # No bounds set; default max is 2.0. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=2.5) + + +def test_rejects_weight_outside_partial_bound() -> None: + # Only weight_min set; weight must respect effective range [weight_min, default_max]. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=0.0, weight_min=0.5) + + # Only weight_max set; weight must respect effective range [default_min, weight_max]. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=1.0, weight_max=0.5) + + +def test_accepts_weight_at_effective_bounds() -> None: + # Inclusive bounds. + LoraModelDefaultSettings(weight=-1.0, weight_min=-1.0, weight_max=2.0) + LoraModelDefaultSettings(weight=2.0, weight_min=-1.0, weight_max=2.0) + + +def test_rejects_extra_fields() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(extra_field=True) # type: ignore[call-arg]