From 8375f95ea9934755b325cc54554a5593c331b6a8 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 19 Mar 2026 04:36:09 +0100 Subject: [PATCH] feat: add resolution presets and imageConfig support for Gemini 3 models Add combined resolution preset selector for external models that maps aspect ratio + image size to fixed dimensions. Gemini 3 Pro and 3.1 Flash now send imageConfig (aspectRatio + imageSize) via generationConfig instead of text-based aspect ratio hints used by Gemini 2.5 Flash. Backend: ExternalResolutionPreset model, resolution_presets capability field, image_size on ExternalGenerationRequest, and Gemini provider imageConfig logic. Frontend: ExternalSettingsAccordion with combo resolution select, dimension slider disabling for fixed-size models, and panel schema constraint wiring for Steps/Guidance/Seed controls. --- .../invocations/external_image_generation.py | 2 + .../external_generation_common.py | 1 + .../external_generation_default.py | 2 + .../external_generation/providers/gemini.py | 11 ++- .../model_manager/configs/external_api.py | 11 +++ .../backend/model_manager/starter_models.py | 44 ++++++++- invokeai/frontend/web/public/locales/en.json | 1 + .../controlLayers/store/paramsSlice.ts | 83 +++++++++++++--- .../src/features/controlLayers/store/types.ts | 2 + .../graph/generation/buildExternalGraph.ts | 1 + .../components/Core/ParamGuidance.tsx | 34 ++++--- .../parameters/components/Core/ParamSteps.tsx | 34 ++++--- .../DimensionsAspectRatioSelect.tsx | 7 +- .../Dimensions/DimensionsHeight.tsx | 5 +- .../DimensionsLockAspectRatioButton.tsx | 8 +- .../DimensionsSetOptimalSizeButton.tsx | 9 +- .../Dimensions/DimensionsSwapButton.tsx | 6 +- .../components/Dimensions/DimensionsWidth.tsx | 5 +- .../ExternalModelImageSizeSelect.tsx | 94 +++++++++++++++++++ .../ExternalModelResolutionSelect.tsx | 68 ++++++++++++++ .../components/Seed/ParamSeedNumberInput.tsx | 15 ++- .../parameters/util/externalPanelSchema.ts | 2 +- .../ExternalSettingsAccordion.tsx | 44 +++++++++ .../ParametersPanelCanvas.tsx | 2 + .../ParametersPanelGenerate.tsx | 2 + .../frontend/web/src/services/api/types.ts | 10 ++ .../test_external_generation_service.py | 1 + .../test_external_provider_adapters.py | 1 + 28 files changed, 455 insertions(+), 50 deletions(-) create mode 100644 invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelImageSizeSelect.tsx create mode 100644 invokeai/frontend/web/src/features/parameters/components/Dimensions/ExternalModelResolutionSelect.tsx create mode 100644 invokeai/frontend/web/src/features/settingsAccordions/components/ExternalSettingsAccordion/ExternalSettingsAccordion.tsx 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')} + + + ); +}); + +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')} + + + ); +}); + +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,