diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 4686ad070e..c13020c608 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1490,6 +1490,7 @@ "copyImage": "Copy Image", "denoisingStrength": "Denoising Strength", "disabledNoRasterContent": "Disabled (No Raster Content)", + "disabledNotSupported": "Not supported by model", "downloadImage": "Download Image", "general": "General", "guidance": "Guidance", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts index ed2c67d529..25bad13f4b 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts @@ -4,9 +4,11 @@ import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/c import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { loraIsEnabledChanged } from 'features/controlLayers/store/lorasSlice'; import { + aspectRatioIdChanged, kleinQwen3EncoderModelSelected, kleinVaeModelSelected, modelChanged, + resolutionPresetSelected, setZImageScheduler, syncedToOptimalDimension, vaeSelected, @@ -24,7 +26,7 @@ import { selectBboxModelBase, selectCanvasSlice, } from 'features/controlLayers/store/selectors'; -import { getEntityIdentifier, isFlux2ReferenceImageConfig } from 'features/controlLayers/store/types'; +import { getEntityIdentifier, isAspectRatioID, isFlux2ReferenceImageConfig } from 'features/controlLayers/store/types'; import { initialFlux2ReferenceImage, initialFluxKontextReferenceImage, @@ -46,7 +48,7 @@ import { selectZImageDiffusersModels, } from 'services/api/hooks/modelsByType'; import type { FLUXKontextModelConfig, FLUXReduxModelConfig, IPAdapterModelConfig } from 'services/api/types'; -import { isFluxKontextModelConfig, isFluxReduxModelConfig } from 'services/api/types'; +import { isExternalApiModelConfig, isFluxKontextModelConfig, isFluxReduxModelConfig } from 'services/api/types'; const log = logger('models'); @@ -352,6 +354,34 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) = dispatch(bboxSyncedToOptimalDimension()); } } + + // When switching to an external model, sync bbox to the model's first preset dimensions + if (newBase === 'external') { + const modelConfigsResult = selectModelConfigsQuery(getState()); + if (modelConfigsResult.data) { + const newModelConfig = modelConfigsAdapterSelectors.selectById(modelConfigsResult.data, newModel.key); + if (newModelConfig && isExternalApiModelConfig(newModelConfig)) { + const { aspect_ratio_sizes, resolution_presets } = newModelConfig.capabilities; + if (resolution_presets && resolution_presets.length > 0) { + const firstPreset = resolution_presets[0]!; + dispatch( + resolutionPresetSelected({ + imageSize: firstPreset.image_size, + aspectRatio: firstPreset.aspect_ratio, + width: firstPreset.width, + height: firstPreset.height, + }) + ); + } else if (aspect_ratio_sizes) { + const firstRatio = Object.keys(aspect_ratio_sizes)[0]; + const firstSize = firstRatio ? aspect_ratio_sizes[firstRatio] : undefined; + if (firstRatio && firstSize && isAspectRatioID(firstRatio)) { + dispatch(aspectRatioIdChanged({ id: firstRatio, fixedSize: firstSize })); + } + } + } + } + } }, }); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ParamDenoisingStrength.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ParamDenoisingStrength.tsx index 34fb96f063..658fb8b745 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ParamDenoisingStrength.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ParamDenoisingStrength.tsx @@ -11,7 +11,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import WavyLine from 'common/components/WavyLine'; -import { selectImg2imgStrength, setImg2imgStrength } from 'features/controlLayers/store/paramsSlice'; +import { selectImg2imgStrength, selectIsExternal, setImg2imgStrength } from 'features/controlLayers/store/paramsSlice'; import { selectActiveRasterLayerEntities } from 'features/controlLayers/store/selectors'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -37,6 +37,7 @@ export const ParamDenoisingStrength = memo(() => { const img2imgStrength = useAppSelector(selectImg2imgStrength); const dispatch = useAppDispatch(); const hasRasterLayersWithContent = useAppSelector(selectHasRasterLayersWithContent); + const isExternal = useAppSelector(selectIsExternal); const selectedModelConfig = useSelectedModelConfig(); const onChange = useCallback( @@ -55,12 +56,16 @@ export const ParamDenoisingStrength = memo(() => { // Denoising strength does nothing if there are no raster layers w/ content return true; } + if (isExternal) { + // External models don't support denoise strength - they handle img2img via prompt + return true; + } if (selectedModelConfig && isFluxFillMainModelModelConfig(selectedModelConfig)) { // Denoising strength is ignored by FLUX Fill, which is indicated by the variant being 'inpaint' return true; } return false; - }, [hasRasterLayersWithContent, selectedModelConfig]); + }, [hasRasterLayersWithContent, isExternal, selectedModelConfig]); return ( @@ -96,7 +101,9 @@ export const ParamDenoisingStrength = memo(() => { ) : ( - {t('parameters.disabledNoRasterContent')} + + {isExternal ? t('parameters.disabledNotSupported') : t('parameters.disabledNoRasterContent')} + )} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBboxToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBboxToolModule.ts index ecf9a5d1c7..2ab2d1f281 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBboxToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBboxToolModule.ts @@ -10,7 +10,7 @@ import { getPrefixedId, } from 'features/controlLayers/konva/util'; import { selectBboxOverlay } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectModel } from 'features/controlLayers/store/paramsSlice'; +import { selectHasFixedDimensionSizes, selectModel } from 'features/controlLayers/store/paramsSlice'; import { selectBbox } from 'features/controlLayers/store/selectors'; import type { Coordinate, Rect, Tool } from 'features/controlLayers/store/types'; import Konva from 'konva'; @@ -191,6 +191,9 @@ export class CanvasBboxToolModule extends CanvasModuleBase { // Listen for the model changing - some model types constraint the bbox to a certain size or aspect ratio. this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectModel, this.render)); + // Listen for fixed dimension sizes changes - external models may lock bbox resizing + this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectHasFixedDimensionSizes, this.render)); + // Update on busy state changes this.subscriptions.add(this.manager.$isBusy.listen(this.render)); @@ -246,6 +249,10 @@ export class CanvasBboxToolModule extends CanvasModuleBase { if (tool !== 'bbox') { return NO_ANCHORS; } + // External models with fixed dimension presets don't allow free bbox resizing + if (this.manager.stateApi.runSelector(selectHasFixedDimensionSizes)) { + return NO_ANCHORS; + } return ALL_ANCHORS; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 79d3963d12..9c283f188f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -7,7 +7,7 @@ import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMul import { merge } from 'es-toolkit/compat'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; -import { modelChanged } from 'features/controlLayers/store/paramsSlice'; +import { aspectRatioIdChanged, modelChanged, resolutionPresetSelected } from 'features/controlLayers/store/paramsSlice'; import { selectAllEntities, selectAllEntitiesOfType, @@ -31,6 +31,7 @@ import type { RgbColor, SimpleAdjustmentsConfig, } from 'features/controlLayers/store/types'; +import { isAspectRatioID } from 'features/controlLayers/store/types'; import { calculateNewSize, getScaledBoundingBoxDimensions, @@ -1279,21 +1280,31 @@ const slice = createSlice({ state.bbox.aspectRatio.isLocked = !state.bbox.aspectRatio.isLocked; syncScaledSize(state); }, - bboxAspectRatioIdChanged: (state, action: PayloadAction<{ id: AspectRatioID }>) => { - const { id } = action.payload; + bboxAspectRatioIdChanged: ( + state, + action: PayloadAction<{ id: AspectRatioID; fixedSize?: { width: number; height: number } }> + ) => { + const { id, fixedSize } = action.payload; state.bbox.aspectRatio.id = id; if (id === 'Free') { state.bbox.aspectRatio.isLocked = false; } else { state.bbox.aspectRatio.isLocked = true; - state.bbox.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio; - const { width, height } = calculateNewSize( - state.bbox.aspectRatio.value, - state.bbox.rect.width * state.bbox.rect.height, - state.bbox.modelBase - ); - state.bbox.rect.width = width; - state.bbox.rect.height = height; + if (fixedSize) { + // External models provide fixed dimensions for each aspect ratio + state.bbox.aspectRatio.value = fixedSize.width / fixedSize.height; + state.bbox.rect.width = fixedSize.width; + state.bbox.rect.height = fixedSize.height; + } else { + state.bbox.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio; + const { width, height } = calculateNewSize( + state.bbox.aspectRatio.value, + state.bbox.rect.width * state.bbox.rect.height, + state.bbox.modelBase + ); + state.bbox.rect.width = width; + state.bbox.rect.height = height; + } } syncScaledSize(state); @@ -1744,6 +1755,29 @@ const slice = createSlice({ syncScaledSize(state); } }); + // Sync bbox when external model resolution preset is selected (aspect_ratio_sizes) + builder.addCase(aspectRatioIdChanged, (state, action) => { + const { id, fixedSize } = action.payload; + // Only sync when fixedSize is provided (external models with aspect_ratio_sizes) + if (fixedSize) { + state.bbox.rect.width = fixedSize.width; + state.bbox.rect.height = fixedSize.height; + state.bbox.aspectRatio.value = fixedSize.width / fixedSize.height; + state.bbox.aspectRatio.id = id; + state.bbox.aspectRatio.isLocked = true; + syncScaledSize(state); + } + }); + // Sync bbox when external model resolution preset is selected (resolution_presets) + builder.addCase(resolutionPresetSelected, (state, action) => { + const { width, height, aspectRatio } = action.payload; + state.bbox.rect.width = width; + state.bbox.rect.height = height; + state.bbox.aspectRatio.value = width / height; + state.bbox.aspectRatio.id = isAspectRatioID(aspectRatio) ? aspectRatio : 'Free'; + state.bbox.aspectRatio.isLocked = true; + syncScaledSize(state); + }); }, }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 77ad6619db..2b09b4b8ed 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -636,19 +636,42 @@ export const zLoRA = z.object({ }); export type LoRA = z.infer; -export const zAspectRatioID = z.enum(['Free', '21:9', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16', '9:21']); +export const zAspectRatioID = z.enum([ + 'Free', + '8:1', + '4:1', + '21:9', + '16:9', + '3:2', + '5:4', + '4:3', + '1:1', + '3:4', + '4:5', + '2:3', + '9:16', + '1:4', + '9:21', + '1:8', +]); export type AspectRatioID = z.infer; export const isAspectRatioID = (v: unknown): v is AspectRatioID => zAspectRatioID.safeParse(v).success; export const ASPECT_RATIO_MAP: Record, { ratio: number; inverseID: AspectRatioID }> = { + '8:1': { ratio: 8 / 1, inverseID: '1:8' }, + '4:1': { ratio: 4 / 1, inverseID: '1:4' }, '21:9': { ratio: 21 / 9, inverseID: '9:21' }, '16:9': { ratio: 16 / 9, inverseID: '9:16' }, '3:2': { ratio: 3 / 2, inverseID: '2:3' }, + '5:4': { ratio: 5 / 4, inverseID: '4:5' }, '4:3': { ratio: 4 / 3, inverseID: '4:3' }, '1:1': { ratio: 1, inverseID: '1:1' }, '3:4': { ratio: 3 / 4, inverseID: '4:3' }, + '4:5': { ratio: 4 / 5, inverseID: '5:4' }, '2:3': { ratio: 2 / 3, inverseID: '3:2' }, '9:16': { ratio: 9 / 16, inverseID: '16:9' }, + '1:4': { ratio: 1 / 4, inverseID: '4:1' }, '9:21': { ratio: 9 / 21, inverseID: '21:9' }, + '1:8': { ratio: 1 / 8, inverseID: '8:1' }, }; const zAspectRatioConfig = z.object({ diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts index 0ba82234a6..2d7ee19897 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildExternalGraph.ts @@ -6,7 +6,6 @@ import { type ModelIdentifierField, zImageField } from 'features/nodes/types/com import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { getOriginalAndScaledSizesForOtherModes, - getOriginalAndScaledSizesForTextToImage, selectCanvasOutputFields, } from 'features/nodes/util/graph/graphBuilderUtils'; import { @@ -110,16 +109,15 @@ export const buildExternalGraph = async (arg: GraphBuilderArg): Promise { const id = useAppSelector(selectAspectRatioID); const isStaging = useCanvasIsStaging(); const allowedAspectRatios = useAppSelector(selectAllowedAspectRatioIDs); - const options = allowedAspectRatios ?? zAspectRatioID.options; + const aspectRatioSizes = useAppSelector(selectAspectRatioSizes); + const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes); + const options = useMemo(() => allowedAspectRatios ?? zAspectRatioID.options, [allowedAspectRatios]); const onChange = useCallback>( (e) => { if (!isAspectRatioID(e.target.value)) { return; } - dispatch(bboxAspectRatioIdChanged({ id: e.target.value })); + const fixedSize = aspectRatioSizes?.[e.target.value] ?? undefined; + dispatch(bboxAspectRatioIdChanged({ id: e.target.value, fixedSize })); + // For external models with fixed sizes, also sync to params so buildExternalGraph uses correct dimensions + if (fixedSize) { + dispatch(aspectRatioIdChanged({ id: e.target.value, fixedSize })); + } }, - [dispatch] + [dispatch, aspectRatioSizes] ); return ( - + {t('parameters.aspect')} diff --git a/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxSwapDimensionsButton.tsx b/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxSwapDimensionsButton.tsx index 54614419a5..372f8187ea 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxSwapDimensionsButton.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Bbox/BboxSwapDimensionsButton.tsx @@ -1,7 +1,8 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxDimensionsSwapped } from 'features/controlLayers/store/canvasSlice'; import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectHasFixedDimensionSizes } from 'features/controlLayers/store/paramsSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsDownUpBold } from 'react-icons/pi'; @@ -10,6 +11,7 @@ export const BboxSwapDimensionsButton = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const isStaging = useCanvasIsStaging(); + const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes); const onClick = useCallback(() => { dispatch(bboxDimensionsSwapped()); }, [dispatch]); @@ -21,7 +23,7 @@ export const BboxSwapDimensionsButton = memo(() => { variant="ghost" size="sm" icon={} - isDisabled={isStaging} + isDisabled={isStaging || hasFixedSizes} /> ); }); diff --git a/invokeai/frontend/web/src/features/parameters/components/Bbox/use-is-bbox-size-locked.ts b/invokeai/frontend/web/src/features/parameters/components/Bbox/use-is-bbox-size-locked.ts index eaf1381108..18f453708e 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Bbox/use-is-bbox-size-locked.ts +++ b/invokeai/frontend/web/src/features/parameters/components/Bbox/use-is-bbox-size-locked.ts @@ -1,6 +1,9 @@ +import { useAppSelector } from 'app/store/storeHooks'; import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectHasFixedDimensionSizes } from 'features/controlLayers/store/paramsSlice'; export const useIsBboxSizeLocked = () => { const isStaging = useCanvasIsStaging(); - return isStaging; + const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes); + return isStaging || hasFixedSizes; };