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.
This commit is contained in:
Alexander Eichhorn
2026-03-19 04:36:09 +01:00
parent 9e4d0bb191
commit 8375f95ea9
28 changed files with 455 additions and 50 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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"),

View File

@@ -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] = {

View File

@@ -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)

View File

@@ -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"}]),

View File

@@ -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 <LinkComponent>account settings</LinkComponent> to upgrade."
},
"dynamicPrompts": {

View File

@@ -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<string | null>) => {
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) {

View File

@@ -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<typeof zParamsState>;
@@ -820,6 +821,7 @@ export const getInitialParamsState = (): ParamsState => ({
zImageSeedVarianceEnabled: false,
zImageSeedVarianceStrength: 0.1,
zImageSeedVarianceRandomizePercent: 50,
imageSize: null,
dimensions: {
width: 512,
height: 512,

View File

@@ -74,6 +74,7 @@ export const buildExternalGraph = async (arg: GraphBuilderArg): Promise<GraphBui
negative_prompt: supportsNegativePrompt ? prompts.negative : null,
steps: supportsSteps ? params.steps : null,
guidance: supportsGuidance ? params.guidance : null,
image_size: params.imageSize ?? null,
num_images: 1,
};
g.addNode(externalNode as AnyInvocation);

View File

@@ -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 { selectGuidance, setGuidance } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { selectGuidance, selectGuidanceControl, setGuidance } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const CONSTRAINTS = {
@@ -23,10 +23,22 @@ export const MARKS = [
const ParamGuidance = () => {
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 (
<FormControl>
<InformationalPopover feature="paramGuidance">
@@ -35,20 +47,20 @@ const ParamGuidance = () => {
<CompositeSlider
value={guidance}
defaultValue={CONSTRAINTS.initial}
min={CONSTRAINTS.sliderMin}
max={CONSTRAINTS.sliderMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
onChange={onChange}
marks={MARKS}
marks={marks}
/>
<CompositeNumberInput
value={guidance}
defaultValue={CONSTRAINTS.initial}
min={CONSTRAINTS.numberInputMin}
max={CONSTRAINTS.numberInputMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
min={numberInputMin}
max={numberInputMax}
step={coarseStep}
fineStep={fineStep}
onChange={onChange}
/>
</FormControl>

View File

@@ -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 (
<FormControl>
<InformationalPopover feature="paramSteps">
@@ -36,20 +48,20 @@ const ParamSteps = () => {
<CompositeSlider
value={steps}
defaultValue={CONSTRAINTS.initial}
min={CONSTRAINTS.sliderMin}
max={CONSTRAINTS.sliderMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
onChange={onChange}
marks={MARKS}
marks={marks}
/>
<CompositeNumberInput
value={steps}
defaultValue={CONSTRAINTS.initial}
min={CONSTRAINTS.numberInputMin}
max={CONSTRAINTS.numberInputMax}
step={CONSTRAINTS.coarseStep}
fineStep={CONSTRAINTS.fineStep}
min={numberInputMin}
max={numberInputMax}
step={coarseStep}
fineStep={fineStep}
onChange={onChange}
/>
</FormControl>

View File

@@ -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<ChangeEventHandler<HTMLSelectElement>>(
@@ -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 (

View File

@@ -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 (
<FormControl>
<FormControl isDisabled={hasFixedSizes}>
<InformationalPopover feature="paramHeight">
<FormLabel>{t('parameters.height')}</FormLabel>
</InformationalPopover>

View File

@@ -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 ? <PiLockSimpleFill /> : <PiLockSimpleOpenBold />}
isDisabled={hasFixedSizes}
/>
);
});

View File

@@ -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={<PiSparkleFill />}
colorScheme={isSizeTooSmall || isSizeTooLarge ? 'warning' : 'base'}
isDisabled={hasFixedSizes}
/>
);
});

View File

@@ -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={<PiArrowsDownUpBold />}
isDisabled={hasFixedSizes}
/>
);
});

View File

@@ -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 (
<FormControl>
<FormControl isDisabled={hasFixedSizes}>
<InformationalPopover feature="paramWidth">
<FormLabel>{t('parameters.width')}</FormLabel>
</InformationalPopover>

View File

@@ -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<string, (typeof presets)[number]>();
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<ChangeEventHandler<HTMLSelectElement>>(
(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 (
<FormControl>
<FormLabel>{t('parameters.resolution')}</FormLabel>
<Select
size="sm"
value={selectedKey}
onChange={onChange}
cursor="pointer"
iconSize="0.75rem"
icon={<PiCaretDownBold />}
>
{presets.map((preset) => {
const key = makeKey(preset.aspect_ratio, preset.image_size);
return (
<option key={key} value={key}>
{preset.label}
</option>
);
})}
</Select>
</FormControl>
);
});
ExternalModelImageSizeSelect.displayName = 'ExternalModelImageSizeSelect';

View File

@@ -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<ChangeEventHandler<HTMLSelectElement>>(
(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 (
<FormControl>
<FormLabel>{t('parameters.resolution')}</FormLabel>
<Select
size="sm"
value={aspectRatioID}
onChange={onChange}
cursor="pointer"
iconSize="0.75rem"
icon={<PiCaretDownBold />}
>
{options.map(({ ratio, label }) => (
<option key={ratio} value={ratio}>
{label}
</option>
))}
</Select>
</FormControl>
);
});
ExternalModelResolutionSelect.displayName = 'ExternalModelResolutionSelect';

View File

@@ -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(() => {
<FormLabel>{t('parameters.seed')}</FormLabel>
</InformationalPopover>
<CompositeNumberInput
step={1}
min={NUMPY_RAND_MIN}
max={NUMPY_RAND_MAX}
step={externalControl?.coarse_step ?? 1}
fineStep={externalControl?.fine_step ?? undefined}
min={externalControl?.number_input_min ?? NUMPY_RAND_MIN}
max={externalControl?.number_input_max ?? NUMPY_RAND_MAX}
onChange={handleChangeSeed}
value={seed}
flexGrow={1}

View File

@@ -25,7 +25,7 @@ const buildExternalPanelSchemaFromCapabilities = (
const getExternalPanelSchema = (modelConfig: ExternalApiModelConfig): ExternalModelPanelSchema =>
modelConfig.panel_schema ?? buildExternalPanelSchemaFromCapabilities(modelConfig.capabilities);
const getExternalPanelControl = (
export const getExternalPanelControl = (
modelConfig: ExternalApiModelConfig,
panel: ExternalPanelName,
controlName: ExternalPanelControlName

View File

@@ -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 (
<StandaloneAccordion
label={t('accordions.advanced.title')}
badges={['EXTERNAL']}
isOpen={isOpen}
onToggle={onToggle}
>
<Flex gap={4} p={4} flexDir="column" data-testid="external-settings-accordion">
<FormControlGroup formLabelProps={formLabelProps}>
<ExternalModelResolutionSelect />
<ExternalModelImageSizeSelect />
</FormControlGroup>
</Flex>
</StandaloneAccordion>
);
});
ExternalSettingsAccordion.displayName = 'ExternalSettingsAccordion';

View File

@@ -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 && <CompositingSettingsAccordion />}
{isSDXL && <RefinerSettingsAccordion />}
{!isCogview4 && !isExternal && <AdvancedSettingsAccordion />}
{isExternal && <ExternalSettingsAccordion />}
</Flex>
</OverlayScrollbarsComponent>
</Box>

View File

@@ -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(() => {
<GenerationSettingsAccordion />
{isSDXL && <RefinerSettingsAccordion />}
{!isCogview4 && !isExternal && <AdvancedSettingsAccordion />}
{isExternal && <ExternalSettingsAccordion />}
</Flex>
</OverlayScrollbarsComponent>
</Box>

View File

@@ -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<string, ExternalImageSize> | null;
resolution_presets?: ExternalResolutionPreset[] | null;
max_reference_images?: number | null;
mask_format?: 'alpha' | 'binary' | 'none';
input_image_required_for?: ('txt2img' | 'img2img' | 'inpaint')[] | null;

View File

@@ -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,

View File

@@ -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,