diff --git a/invokeai/app/invocations/external_image_generation.py b/invokeai/app/invocations/external_image_generation.py
index 983dc5caf5..c66b024bfa 100644
--- a/invokeai/app/invocations/external_image_generation.py
+++ b/invokeai/app/invocations/external_image_generation.py
@@ -39,6 +39,7 @@ class BaseExternalImageGenerationInvocation(BaseInvocation, WithMetadata, WithBo
num_images: int = InputField(default=1, gt=0, description="Number of images to generate")
width: int = InputField(default=1024, gt=0, description=FieldDescriptions.width)
height: int = InputField(default=1024, gt=0, description=FieldDescriptions.height)
+ image_size: str | None = InputField(default=None, description="Image size preset (e.g. 1K, 2K, 4K)")
steps: int | None = InputField(default=None, gt=0, description=FieldDescriptions.steps)
guidance: float | None = InputField(default=None, ge=0, description="Guidance strength")
init_image: ImageField | None = InputField(default=None, description="Init image for img2img/inpaint")
@@ -91,6 +92,7 @@ class BaseExternalImageGenerationInvocation(BaseInvocation, WithMetadata, WithBo
num_images=self.num_images,
width=self.width,
height=self.height,
+ image_size=self.image_size,
steps=self.steps,
guidance=self.guidance,
init_image=init_image,
diff --git a/invokeai/app/services/external_generation/external_generation_common.py b/invokeai/app/services/external_generation/external_generation_common.py
index c1e2f4706f..a6746913c1 100644
--- a/invokeai/app/services/external_generation/external_generation_common.py
+++ b/invokeai/app/services/external_generation/external_generation_common.py
@@ -25,6 +25,7 @@ class ExternalGenerationRequest:
num_images: int
width: int
height: int
+ image_size: str | None
steps: int | None
guidance: float | None
init_image: PILImageType | None
diff --git a/invokeai/app/services/external_generation/external_generation_default.py b/invokeai/app/services/external_generation/external_generation_default.py
index ff54d71476..9265e63b7f 100644
--- a/invokeai/app/services/external_generation/external_generation_default.py
+++ b/invokeai/app/services/external_generation/external_generation_default.py
@@ -164,6 +164,7 @@ class ExternalGenerationService(ExternalGenerationServiceBase):
num_images=request.num_images,
width=request.width,
height=request.height,
+ image_size=request.image_size,
steps=request.steps,
guidance=request.guidance,
init_image=request.init_image,
@@ -234,6 +235,7 @@ class ExternalGenerationService(ExternalGenerationServiceBase):
num_images=request.num_images,
width=width,
height=height,
+ image_size=request.image_size,
steps=request.steps,
guidance=request.guidance,
init_image=_resize_image(request.init_image, width, height, "RGB"),
diff --git a/invokeai/app/services/external_generation/providers/gemini.py b/invokeai/app/services/external_generation/providers/gemini.py
index 4d43431a14..855d64d945 100644
--- a/invokeai/app/services/external_generation/providers/gemini.py
+++ b/invokeai/app/services/external_generation/providers/gemini.py
@@ -73,6 +73,15 @@ class GeminiProvider(ExternalProvider):
request.height,
request.model.capabilities.allowed_aspect_ratios,
)
+ uses_image_config = request.model.capabilities.resolution_presets is not None
+ if uses_image_config:
+ image_config: dict[str, str] = {}
+ if aspect_ratio is not None:
+ image_config["aspectRatio"] = aspect_ratio
+ if request.image_size is not None:
+ image_config["imageSize"] = request.image_size
+ if image_config:
+ generation_config["imageConfig"] = image_config
system_instruction = self._SYSTEM_INSTRUCTION
if request.init_image is not None:
system_instruction = (
@@ -80,7 +89,7 @@ class GeminiProvider(ExternalProvider):
"Treat the prompt as an edit instruction and modify the image accordingly. "
"Do not return the original image unchanged."
)
- if aspect_ratio is not None:
+ if not uses_image_config and aspect_ratio is not None:
system_instruction = f"{system_instruction} Use an aspect ratio of {aspect_ratio}."
payload: dict[str, object] = {
diff --git a/invokeai/backend/model_manager/configs/external_api.py b/invokeai/backend/model_manager/configs/external_api.py
index 50c51e28cf..4d105a65f6 100644
--- a/invokeai/backend/model_manager/configs/external_api.py
+++ b/invokeai/backend/model_manager/configs/external_api.py
@@ -19,6 +19,16 @@ class ExternalImageSize(BaseModel):
model_config = ConfigDict(extra="forbid")
+class ExternalResolutionPreset(BaseModel):
+ label: str = Field(min_length=1, description="Display label, e.g. '1:1 (1K)'")
+ aspect_ratio: str = Field(min_length=1, description="Aspect ratio string, e.g. '1:1'")
+ image_size: str = Field(min_length=1, description="Image size preset, e.g. '1K'")
+ width: int = Field(gt=0)
+ height: int = Field(gt=0)
+
+ model_config = ConfigDict(extra="forbid")
+
+
class ExternalModelCapabilities(BaseModel):
modes: list[ExternalGenerationMode] = Field(default_factory=lambda: ["txt2img"])
supports_reference_images: bool = Field(default=False)
@@ -30,6 +40,7 @@ class ExternalModelCapabilities(BaseModel):
max_image_size: ExternalImageSize | None = Field(default=None)
allowed_aspect_ratios: list[str] | None = Field(default=None)
aspect_ratio_sizes: dict[str, ExternalImageSize] | None = Field(default=None)
+ resolution_presets: list[ExternalResolutionPreset] | None = Field(default=None)
max_reference_images: int | None = Field(default=None, gt=0)
mask_format: ExternalMaskFormat = Field(default="none")
input_image_required_for: list[ExternalGenerationMode] | None = Field(default=None)
diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py
index 0d6671cf7e..5e9d422681 100644
--- a/invokeai/backend/model_manager/starter_models.py
+++ b/invokeai/backend/model_manager/starter_models.py
@@ -7,6 +7,7 @@ from invokeai.backend.model_manager.configs.external_api import (
ExternalImageSize,
ExternalModelCapabilities,
ExternalModelPanelSchema,
+ ExternalResolutionPreset,
)
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType
@@ -890,6 +891,45 @@ GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS = [
]
GEMINI_3_IMAGE_MAX_SIZE = ExternalImageSize(width=4096, height=4096)
+
+def _gemini_3_resolution_presets(
+ image_sizes: list[str],
+ aspect_ratios: list[str] | None = None,
+) -> list[ExternalResolutionPreset]:
+ """Build resolution presets for Gemini 3 models.
+
+ Each preset combines an aspect ratio with an image size preset (512/1K/2K/4K).
+ Pixel dimensions are approximations based on the preset name (longest side).
+ """
+ if aspect_ratios is None:
+ aspect_ratios = GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS
+ base_pixels = {"512": 512, "1K": 1024, "2K": 2048, "4K": 4096}
+ presets: list[ExternalResolutionPreset] = []
+ for image_size in image_sizes:
+ base = base_pixels[image_size]
+ for ratio_str in aspect_ratios:
+ w_part, h_part = (int(x) for x in ratio_str.split(":"))
+ if w_part >= h_part:
+ w = base
+ h = max(1, round(base * h_part / w_part))
+ else:
+ h = base
+ w = max(1, round(base * w_part / h_part))
+ presets.append(
+ ExternalResolutionPreset(
+ label=f"{ratio_str} ({image_size}) — {w}\u00d7{h}",
+ aspect_ratio=ratio_str,
+ image_size=image_size,
+ width=w,
+ height=h,
+ )
+ )
+ return presets
+
+
+GEMINI_3_PRO_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["1K", "2K", "4K"])
+GEMINI_3_1_FLASH_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["512", "1K", "2K", "4K"])
+
gemini_flash_image = StarterModel(
name="Gemini 2.5 Flash Image",
base=BaseModelType.External,
@@ -936,7 +976,7 @@ gemini_pro_image_preview = StarterModel(
name="Gemini 3 Pro Image Preview",
base=BaseModelType.External,
source="external://gemini/gemini-3-pro-image-preview",
- description="Google Gemini 3 Pro image generation preview model (external API). Supports up to 14 reference images, including up to 6 object references and up to 5 character references. Supports 512/1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.",
+ description="Google Gemini 3 Pro image generation preview model (external API). Supports up to 14 reference images, including up to 6 object references and up to 5 character references. Supports 1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
@@ -949,6 +989,7 @@ gemini_pro_image_preview = StarterModel(
max_images_per_request=1,
max_image_size=GEMINI_3_IMAGE_MAX_SIZE,
allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS,
+ resolution_presets=GEMINI_3_PRO_RESOLUTION_PRESETS,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]),
@@ -970,6 +1011,7 @@ gemini_3_1_flash_image_preview = StarterModel(
max_images_per_request=1,
max_image_size=GEMINI_3_IMAGE_MAX_SIZE,
allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS,
+ resolution_presets=GEMINI_3_1_FLASH_RESOLUTION_PRESETS,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]),
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index c961b8e8fc..db515785f3 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1601,6 +1601,7 @@
"boxBlur": "Box Blur",
"staged": "Staged",
"resolution": "Resolution",
+ "imageSize": "Image Size",
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your account settings to upgrade."
},
"dynamicPrompts": {
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts
index 29fa709907..87b3789a32 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts
@@ -42,7 +42,7 @@ import type {
ParameterT5EncoderModel,
ParameterVAEModel,
} from 'features/parameters/types/parameterSchemas';
-import { hasExternalPanelControl } from 'features/parameters/util/externalPanelSchema';
+import { getExternalPanelControl, hasExternalPanelControl } from 'features/parameters/util/externalPanelSchema';
import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models';
import type { AnyModelConfigWithExternal } from 'services/api/types';
@@ -366,21 +366,30 @@ const slice = createSlice({
aspectRatioLockToggled: (state) => {
state.dimensions.aspectRatio.isLocked = !state.dimensions.aspectRatio.isLocked;
},
- aspectRatioIdChanged: (state, action: PayloadAction<{ id: AspectRatioID }>) => {
- const { id } = action.payload;
+ aspectRatioIdChanged: (
+ state,
+ action: PayloadAction<{ id: AspectRatioID; fixedSize?: { width: number; height: number } }>
+ ) => {
+ const { id, fixedSize } = action.payload;
state.dimensions.aspectRatio.id = id;
if (id === 'Free') {
state.dimensions.aspectRatio.isLocked = false;
} else {
state.dimensions.aspectRatio.isLocked = true;
- state.dimensions.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio;
- const { width, height } = calculateNewSize(
- state.dimensions.aspectRatio.value,
- state.dimensions.width * state.dimensions.height,
- state.model?.base as BaseModelType | undefined
- );
- state.dimensions.width = width;
- state.dimensions.height = height;
+ if (fixedSize) {
+ state.dimensions.aspectRatio.value = fixedSize.width / fixedSize.height;
+ state.dimensions.width = fixedSize.width;
+ state.dimensions.height = fixedSize.height;
+ } else {
+ state.dimensions.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio;
+ const { width, height } = calculateNewSize(
+ state.dimensions.aspectRatio.value,
+ state.dimensions.width * state.dimensions.height,
+ state.model?.base as BaseModelType | undefined
+ );
+ state.dimensions.width = width;
+ state.dimensions.height = height;
+ }
}
},
dimensionsSwapped: (state) => {
@@ -436,6 +445,21 @@ const slice = createSlice({
state.dimensions.height = bboxDims.height;
}
},
+ imageSizeChanged: (state, action: PayloadAction) => {
+ state.imageSize = action.payload;
+ },
+ resolutionPresetSelected: (
+ state,
+ action: PayloadAction<{ imageSize: string; aspectRatio: string; width: number; height: number }>
+ ) => {
+ const { imageSize, aspectRatio, width, height } = action.payload;
+ state.imageSize = imageSize;
+ state.dimensions.width = width;
+ state.dimensions.height = height;
+ state.dimensions.aspectRatio.id = aspectRatio as AspectRatioID;
+ state.dimensions.aspectRatio.value = width / height;
+ state.dimensions.aspectRatio.isLocked = true;
+ },
paramsReset: (state) => resetState(state),
},
extraReducers(builder) {
@@ -567,6 +591,7 @@ export const {
sizeOptimized,
syncedToOptimalDimension,
+ resolutionPresetSelected,
paramsReset,
} = slice.actions;
@@ -737,6 +762,24 @@ export const selectModelSupportsDimensions = createSelector(selectModel, selectM
}
return true;
});
+export const selectStepsControl = createSelector(selectModelConfig, (modelConfig) => {
+ if (modelConfig && isExternalApiModelConfig(modelConfig)) {
+ return getExternalPanelControl(modelConfig, 'generation', 'steps');
+ }
+ return null;
+});
+export const selectGuidanceControl = createSelector(selectModelConfig, (modelConfig) => {
+ if (modelConfig && isExternalApiModelConfig(modelConfig)) {
+ return getExternalPanelControl(modelConfig, 'generation', 'guidance');
+ }
+ return null;
+});
+export const selectSeedControl = createSelector(selectModelConfig, (modelConfig) => {
+ if (modelConfig && isExternalApiModelConfig(modelConfig)) {
+ return getExternalPanelControl(modelConfig, 'image', 'seed');
+ }
+ return null;
+});
export const selectScheduler = createParamsSelector((params) => params.scheduler);
export const selectFluxScheduler = createParamsSelector((params) => params.fluxScheduler);
export const selectFluxDypePreset = createParamsSelector((params) => params.fluxDypePreset);
@@ -786,6 +829,24 @@ export const selectAllowedAspectRatioIDs = createSelector(selectModelConfig, (mo
const allowed = modelConfig.capabilities.allowed_aspect_ratios;
return allowed?.length ? allowed : null;
});
+export const selectAspectRatioSizes = createSelector(selectModelConfig, (modelConfig) => {
+ if (!modelConfig || !isExternalApiModelConfig(modelConfig)) {
+ return null;
+ }
+ return modelConfig.capabilities.aspect_ratio_sizes ?? null;
+});
+export const selectResolutionPresets = createSelector(selectModelConfig, (modelConfig) => {
+ if (!modelConfig || !isExternalApiModelConfig(modelConfig)) {
+ return null;
+ }
+ return modelConfig.capabilities.resolution_presets ?? null;
+});
+export const selectHasFixedDimensionSizes = createSelector(
+ selectAspectRatioSizes,
+ selectResolutionPresets,
+ (sizes, presets) => sizes !== null || (presets !== null && presets.length > 0)
+);
+export const selectImageSize = createParamsSelector((params) => params.imageSize);
export const selectMainModelConfig = createSelector(selectModelConfig, (modelConfig) => {
if (!modelConfig) {
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index 40babc7bc8..8c62f73b8e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -757,6 +757,7 @@ export const zParamsState = z.object({
zImageSeedVarianceEnabled: z.boolean(),
zImageSeedVarianceStrength: z.number().min(0).max(2),
zImageSeedVarianceRandomizePercent: z.number().min(1).max(100),
+ imageSize: z.string().nullable().default(null),
dimensions: zDimensionsState,
});
export type ParamsState = z.infer;
@@ -820,6 +821,7 @@ export const getInitialParamsState = (): ParamsState => ({
zImageSeedVarianceEnabled: false,
zImageSeedVarianceStrength: 0.1,
zImageSeedVarianceRandomizePercent: 50,
+ imageSize: null,
dimensions: {
width: 512,
height: 512,
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 db3325d8fa..4f686a5986 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
@@ -74,6 +74,7 @@ export const buildExternalGraph = async (arg: GraphBuilderArg): Promise {
const guidance = useAppSelector(selectGuidance);
+ const externalControl = useAppSelector(selectGuidanceControl);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const onChange = useCallback((v: number) => dispatch(setGuidance(v)), [dispatch]);
+ const sliderMin = externalControl?.slider_min ?? CONSTRAINTS.sliderMin;
+ const sliderMax = externalControl?.slider_max ?? CONSTRAINTS.sliderMax;
+ const numberInputMin = externalControl?.number_input_min ?? CONSTRAINTS.numberInputMin;
+ const numberInputMax = externalControl?.number_input_max ?? CONSTRAINTS.numberInputMax;
+ const fineStep = externalControl?.fine_step ?? CONSTRAINTS.fineStep;
+ const coarseStep = externalControl?.coarse_step ?? CONSTRAINTS.coarseStep;
+ const marks = useMemo(
+ () => externalControl?.marks ?? [sliderMin, Math.floor(sliderMax - (sliderMax - sliderMin) / 2), sliderMax],
+ [externalControl?.marks, sliderMin, sliderMax]
+ );
+
return (
@@ -35,20 +47,20 @@ const ParamGuidance = () => {
diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx
index 31efe5d0a6..b6d810dd2e 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamSteps.tsx
@@ -1,8 +1,8 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import { selectSteps, setSteps } from 'features/controlLayers/store/paramsSlice';
-import { memo, useCallback } from 'react';
+import { selectSteps, selectStepsControl, setSteps } from 'features/controlLayers/store/paramsSlice';
+import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const CONSTRAINTS = {
@@ -19,6 +19,7 @@ export const MARKS = [CONSTRAINTS.sliderMin, Math.floor(CONSTRAINTS.sliderMax /
const ParamSteps = () => {
const steps = useAppSelector(selectSteps);
+ const externalControl = useAppSelector(selectStepsControl);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const onChange = useCallback(
@@ -28,6 +29,17 @@ const ParamSteps = () => {
[dispatch]
);
+ const sliderMin = externalControl?.slider_min ?? CONSTRAINTS.sliderMin;
+ const sliderMax = externalControl?.slider_max ?? CONSTRAINTS.sliderMax;
+ const numberInputMin = externalControl?.number_input_min ?? CONSTRAINTS.numberInputMin;
+ const numberInputMax = externalControl?.number_input_max ?? CONSTRAINTS.numberInputMax;
+ const fineStep = externalControl?.fine_step ?? CONSTRAINTS.fineStep;
+ const coarseStep = externalControl?.coarse_step ?? CONSTRAINTS.coarseStep;
+ const marks = useMemo(
+ () => externalControl?.marks ?? [sliderMin, Math.floor(sliderMax / 2), sliderMax],
+ [externalControl?.marks, sliderMin, sliderMax]
+ );
+
return (
@@ -36,20 +48,20 @@ const ParamSteps = () => {
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx
index 5e2952552c..5beb1231b0 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx
@@ -5,6 +5,7 @@ import {
aspectRatioIdChanged,
selectAllowedAspectRatioIDs,
selectAspectRatioID,
+ selectAspectRatioSizes,
} from 'features/controlLayers/store/paramsSlice';
import { isAspectRatioID, zAspectRatioID } from 'features/controlLayers/store/types';
import type { ChangeEventHandler } from 'react';
@@ -17,6 +18,7 @@ export const DimensionsAspectRatioSelect = memo(() => {
const dispatch = useAppDispatch();
const id = useAppSelector(selectAspectRatioID);
const allowedAspectRatios = useAppSelector(selectAllowedAspectRatioIDs);
+ const aspectRatioSizes = useAppSelector(selectAspectRatioSizes);
const options = allowedAspectRatios ?? zAspectRatioID.options;
const onChange = useCallback>(
@@ -24,9 +26,10 @@ export const DimensionsAspectRatioSelect = memo(() => {
if (!isAspectRatioID(e.target.value)) {
return;
}
- dispatch(aspectRatioIdChanged({ id: e.target.value }));
+ const fixedSize = aspectRatioSizes?.[e.target.value] ?? undefined;
+ dispatch(aspectRatioIdChanged({ id: e.target.value, fixedSize }));
},
- [dispatch]
+ [dispatch, aspectRatioSizes]
);
return (
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx
index 924187c1ed..81e8101462 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx
@@ -1,7 +1,7 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import { heightChanged, selectHeight } from 'features/controlLayers/store/paramsSlice';
+import { heightChanged, selectHasFixedDimensionSizes, selectHeight } from 'features/controlLayers/store/paramsSlice';
import { selectGridSize, selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -22,6 +22,7 @@ export const DimensionsHeight = memo(() => {
const optimalDimension = useAppSelector(selectOptimalDimension);
const height = useAppSelector(selectHeight);
const gridSize = useAppSelector(selectGridSize);
+ const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
const onChange = useCallback(
(v: number) => {
@@ -33,7 +34,7 @@ export const DimensionsHeight = memo(() => {
const marks = useMemo(() => [CONSTRAINTS.sliderMin, optimalDimension, CONSTRAINTS.sliderMax], [optimalDimension]);
return (
-
+
{t('parameters.height')}
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsLockAspectRatioButton.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsLockAspectRatioButton.tsx
index 6ab17147a7..e3e2c6d59e 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsLockAspectRatioButton.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsLockAspectRatioButton.tsx
@@ -1,6 +1,10 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { aspectRatioLockToggled, selectAspectRatioIsLocked } from 'features/controlLayers/store/paramsSlice';
+import {
+ aspectRatioLockToggled,
+ selectAspectRatioIsLocked,
+ selectHasFixedDimensionSizes,
+} from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiLockSimpleFill, PiLockSimpleOpenBold } from 'react-icons/pi';
@@ -9,6 +13,7 @@ export const DimensionsLockAspectRatioButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isLocked = useAppSelector(selectAspectRatioIsLocked);
+ const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
const onClick = useCallback(() => {
dispatch(aspectRatioLockToggled());
@@ -22,6 +27,7 @@ export const DimensionsLockAspectRatioButton = memo(() => {
variant={isLocked ? 'outline' : 'ghost'}
size="sm"
icon={isLocked ? : }
+ isDisabled={hasFixedSizes}
/>
);
});
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSetOptimalSizeButton.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSetOptimalSizeButton.tsx
index c1c43f0cec..90f6262c5a 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSetOptimalSizeButton.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSetOptimalSizeButton.tsx
@@ -1,6 +1,11 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { selectHeight, selectWidth, sizeOptimized } from 'features/controlLayers/store/paramsSlice';
+import {
+ selectHasFixedDimensionSizes,
+ selectHeight,
+ selectWidth,
+ sizeOptimized,
+} from 'features/controlLayers/store/paramsSlice';
import { selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { getIsSizeTooLarge, getIsSizeTooSmall } from 'features/parameters/util/optimalDimension';
import { memo, useCallback, useMemo } from 'react';
@@ -13,6 +18,7 @@ export const DimensionsSetOptimalSizeButton = memo(() => {
const width = useAppSelector(selectWidth);
const height = useAppSelector(selectHeight);
const optimalDimension = useAppSelector(selectOptimalDimension);
+ const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
const isSizeTooSmall = useMemo(
() => getIsSizeTooSmall(width, height, optimalDimension),
[height, width, optimalDimension]
@@ -43,6 +49,7 @@ export const DimensionsSetOptimalSizeButton = memo(() => {
size="sm"
icon={}
colorScheme={isSizeTooSmall || isSizeTooLarge ? 'warning' : 'base'}
+ isDisabled={hasFixedSizes}
/>
);
});
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSwapButton.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSwapButton.tsx
index 817a81996f..1b5923cddf 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSwapButton.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsSwapButton.tsx
@@ -1,6 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
-import { useAppDispatch } from 'app/store/storeHooks';
-import { dimensionsSwapped } from 'features/controlLayers/store/paramsSlice';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { dimensionsSwapped, selectHasFixedDimensionSizes } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsDownUpBold } from 'react-icons/pi';
@@ -8,6 +8,7 @@ import { PiArrowsDownUpBold } from 'react-icons/pi';
export const DimensionsSwapButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
+ const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
const onClick = useCallback(() => {
dispatch(dimensionsSwapped());
}, [dispatch]);
@@ -19,6 +20,7 @@ export const DimensionsSwapButton = memo(() => {
variant="ghost"
size="sm"
icon={}
+ isDisabled={hasFixedSizes}
/>
);
});
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx
index 20a754c5c3..c70a786c89 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx
@@ -1,7 +1,7 @@
import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import { selectWidth, widthChanged } from 'features/controlLayers/store/paramsSlice';
+import { selectHasFixedDimensionSizes, selectWidth, widthChanged } from 'features/controlLayers/store/paramsSlice';
import { selectGridSize, selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -22,6 +22,7 @@ export const DimensionsWidth = memo(() => {
const width = useAppSelector(selectWidth);
const optimalDimension = useAppSelector(selectOptimalDimension);
const gridSize = useAppSelector(selectGridSize);
+ const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
const onChange = useCallback(
(v: number) => {
@@ -33,7 +34,7 @@ export const DimensionsWidth = memo(() => {
const marks = useMemo(() => [CONSTRAINTS.sliderMin, optimalDimension, CONSTRAINTS.sliderMax], [optimalDimension]);
return (
-
+
{t('parameters.width')}
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelImageSizeSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelImageSizeSelect.tsx
new file mode 100644
index 0000000000..0ad995cf72
--- /dev/null
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelImageSizeSelect.tsx
@@ -0,0 +1,94 @@
+import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import {
+ resolutionPresetSelected,
+ selectAspectRatioID,
+ selectImageSize,
+ selectResolutionPresets,
+} from 'features/controlLayers/store/paramsSlice';
+import type { ChangeEventHandler } from 'react';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCaretDownBold } from 'react-icons/pi';
+
+const makeKey = (aspectRatio: string, imageSize: string) => `${aspectRatio}|${imageSize}`;
+
+export const ExternalModelImageSizeSelect = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const presets = useAppSelector(selectResolutionPresets);
+ const currentAspectRatio = useAppSelector(selectAspectRatioID);
+ const currentImageSize = useAppSelector(selectImageSize);
+
+ const presetMap = useMemo(() => {
+ if (!presets) {
+ return null;
+ }
+ const map = new Map();
+ for (const preset of presets) {
+ map.set(makeKey(preset.aspect_ratio, preset.image_size), preset);
+ }
+ return map;
+ }, [presets]);
+
+ const selectedKey = useMemo(() => {
+ if (!presets || presets.length === 0) {
+ return '';
+ }
+ if (currentImageSize && currentAspectRatio) {
+ const key = makeKey(currentAspectRatio, currentImageSize);
+ if (presetMap?.has(key)) {
+ return key;
+ }
+ }
+ // Fallback to first preset
+ return makeKey(presets[0]!.aspect_ratio, presets[0]!.image_size);
+ }, [presets, presetMap, currentAspectRatio, currentImageSize]);
+
+ const onChange = useCallback>(
+ (e) => {
+ const preset = presetMap?.get(e.target.value);
+ if (!preset) {
+ return;
+ }
+ dispatch(
+ resolutionPresetSelected({
+ imageSize: preset.image_size,
+ aspectRatio: preset.aspect_ratio,
+ width: preset.width,
+ height: preset.height,
+ })
+ );
+ },
+ [dispatch, presetMap]
+ );
+
+ if (!presets || presets.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {t('parameters.resolution')}
+ }
+ >
+ {presets.map((preset) => {
+ const key = makeKey(preset.aspect_ratio, preset.image_size);
+ return (
+
+ );
+ })}
+
+
+ );
+});
+
+ExternalModelImageSizeSelect.displayName = 'ExternalModelImageSizeSelect';
diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelResolutionSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelResolutionSelect.tsx
new file mode 100644
index 0000000000..babb4bcdec
--- /dev/null
+++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelResolutionSelect.tsx
@@ -0,0 +1,68 @@
+import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import {
+ aspectRatioIdChanged,
+ selectAspectRatioID,
+ selectAspectRatioSizes,
+} from 'features/controlLayers/store/paramsSlice';
+import { isAspectRatioID } from 'features/controlLayers/store/types';
+import type { ChangeEventHandler } from 'react';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCaretDownBold } from 'react-icons/pi';
+
+export const ExternalModelResolutionSelect = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const aspectRatioID = useAppSelector(selectAspectRatioID);
+ const aspectRatioSizes = useAppSelector(selectAspectRatioSizes);
+
+ const options = useMemo(() => {
+ if (!aspectRatioSizes) {
+ return [];
+ }
+ return Object.entries(aspectRatioSizes).map(([ratio, size]) => ({
+ ratio,
+ label: `${ratio} (${size.width}×${size.height})`,
+ size,
+ }));
+ }, [aspectRatioSizes]);
+
+ const onChange = useCallback>(
+ (e) => {
+ const ratio = e.target.value;
+ if (!isAspectRatioID(ratio)) {
+ return;
+ }
+ const fixedSize = aspectRatioSizes?.[ratio] ?? undefined;
+ dispatch(aspectRatioIdChanged({ id: ratio, fixedSize }));
+ },
+ [dispatch, aspectRatioSizes]
+ );
+
+ if (!aspectRatioSizes) {
+ return null;
+ }
+
+ return (
+
+ {t('parameters.resolution')}
+ }
+ >
+ {options.map(({ ratio, label }) => (
+
+ ))}
+
+
+ );
+});
+
+ExternalModelResolutionSelect.displayName = 'ExternalModelResolutionSelect';
diff --git a/invokeai/frontend/web/src/features/parameters/components/Seed/ParamSeedNumberInput.tsx b/invokeai/frontend/web/src/features/parameters/components/Seed/ParamSeedNumberInput.tsx
index d7e5ac2ecc..7c5ab19ec0 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Seed/ParamSeedNumberInput.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Seed/ParamSeedNumberInput.tsx
@@ -2,13 +2,19 @@ import { CompositeNumberInput, FormControl, FormLabel } from '@invoke-ai/ui-libr
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from 'app/constants';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import { selectSeed, selectShouldRandomizeSeed, setSeed } from 'features/controlLayers/store/paramsSlice';
+import {
+ selectSeed,
+ selectSeedControl,
+ selectShouldRandomizeSeed,
+ setSeed,
+} from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const ParamSeedNumberInput = memo(() => {
const seed = useAppSelector(selectSeed);
const shouldRandomizeSeed = useAppSelector(selectShouldRandomizeSeed);
+ const externalControl = useAppSelector(selectSeedControl);
const { t } = useTranslation();
@@ -22,9 +28,10 @@ export const ParamSeedNumberInput = memo(() => {
{t('parameters.seed')}
modelConfig.panel_schema ?? buildExternalPanelSchemaFromCapabilities(modelConfig.capabilities);
-const getExternalPanelControl = (
+export const getExternalPanelControl = (
modelConfig: ExternalApiModelConfig,
panel: ExternalPanelName,
controlName: ExternalPanelControlName
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx
new file mode 100644
index 0000000000..deb3ea2c83
--- /dev/null
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx
@@ -0,0 +1,44 @@
+import type { FormLabelProps } from '@invoke-ai/ui-library';
+import { Flex, FormControlGroup, StandaloneAccordion } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectIsExternal } from 'features/controlLayers/store/paramsSlice';
+import { ExternalModelImageSizeSelect } from 'features/parameters/components/Dimensions/ExternalModelImageSizeSelect';
+import { ExternalModelResolutionSelect } from 'features/parameters/components/Dimensions/ExternalModelResolutionSelect';
+import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const formLabelProps: FormLabelProps = {
+ minW: '4rem',
+};
+
+export const ExternalSettingsAccordion = memo(() => {
+ const { t } = useTranslation();
+ const isExternal = useAppSelector(selectIsExternal);
+ const { isOpen, onToggle } = useStandaloneAccordionToggle({
+ id: 'external-settings',
+ defaultIsOpen: true,
+ });
+
+ if (!isExternal) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+});
+
+ExternalSettingsAccordion.displayName = 'ExternalSettingsAccordion';
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx
index 38f99590ec..dec9a9c6cf 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx
@@ -6,6 +6,7 @@ import { selectIsCogView4, selectIsExternal, selectIsSDXL } from 'features/contr
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion';
import { CompositingSettingsAccordion } from 'features/settingsAccordions/components/CompositingSettingsAccordion/CompositingSettingsAccordion';
+import { ExternalSettingsAccordion } from 'features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion';
import { GenerationSettingsAccordion } from 'features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion';
import { CanvasTabImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/CanvasTabImageSettingsAccordion';
import { RefinerSettingsAccordion } from 'features/settingsAccordions/components/RefinerSettingsAccordion/RefinerSettingsAccordion';
@@ -47,6 +48,7 @@ export const ParametersPanelCanvas = memo(() => {
{!isExternal && }
{isSDXL && }
{!isCogview4 && !isExternal && }
+ {isExternal && }
diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelGenerate.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelGenerate.tsx
index 5d1ea8193b..06a122cc4e 100644
--- a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelGenerate.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelGenerate.tsx
@@ -5,6 +5,7 @@ import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/con
import { selectIsCogView4, selectIsExternal, selectIsSDXL } from 'features/controlLayers/store/paramsSlice';
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion';
+import { ExternalSettingsAccordion } from 'features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion';
import { GenerationSettingsAccordion } from 'features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion';
import { GenerateTabImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/GenerateTabImageSettingsAccordion';
import { RefinerSettingsAccordion } from 'features/settingsAccordions/components/RefinerSettingsAccordion/RefinerSettingsAccordion';
@@ -45,6 +46,7 @@ export const ParametersPanelGenerate = memo(() => {
{isSDXL && }
{!isCogview4 && !isExternal && }
+ {isExternal && }
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index 6a5b539d7e..6c89dbc76b 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -131,6 +131,14 @@ export type ExternalImageSize = {
height: number;
};
+type ExternalResolutionPreset = {
+ label: string;
+ aspect_ratio: string;
+ image_size: string;
+ width: number;
+ height: number;
+};
+
export type ExternalModelCapabilities = {
modes: ('txt2img' | 'img2img' | 'inpaint')[];
supports_reference_images?: boolean;
@@ -141,6 +149,8 @@ export type ExternalModelCapabilities = {
max_images_per_request?: number | null;
max_image_size?: ExternalImageSize | null;
allowed_aspect_ratios?: string[] | null;
+ aspect_ratio_sizes?: Record | null;
+ resolution_presets?: ExternalResolutionPreset[] | null;
max_reference_images?: number | null;
mask_format?: 'alpha' | 'binary' | 'none';
input_image_required_for?: ('txt2img' | 'img2img' | 'inpaint')[] | null;
diff --git a/tests/app/services/external_generation/test_external_generation_service.py b/tests/app/services/external_generation/test_external_generation_service.py
index fe3dd9d532..4ad0899c9c 100644
--- a/tests/app/services/external_generation/test_external_generation_service.py
+++ b/tests/app/services/external_generation/test_external_generation_service.py
@@ -74,6 +74,7 @@ def _build_request(
num_images=num_images,
width=width,
height=height,
+ image_size=None,
steps=10,
guidance=guidance,
init_image=init_image,
diff --git a/tests/app/services/external_generation/test_external_provider_adapters.py b/tests/app/services/external_generation/test_external_provider_adapters.py
index c4da4c913b..a7493ec056 100644
--- a/tests/app/services/external_generation/test_external_provider_adapters.py
+++ b/tests/app/services/external_generation/test_external_provider_adapters.py
@@ -64,6 +64,7 @@ def _build_request(
num_images=1,
width=256,
height=256,
+ image_size=None,
steps=20,
guidance=5.5,
init_image=init_image,