feat: full canvas workflow integration for external models

- Add missing aspect ratios (4:5, 5:4, 8:1, 4:1, 1:4, 1:8) to type
  system for external model support
- Sync canvas bbox when external model resolution preset is selected
- Use params preset dimensions in buildExternalGraph to prevent
  "unsupported aspect ratio" errors
- Lock all bbox controls (resize handles, aspect ratio select,
  width/height sliders, swap/optimal buttons) for external models
  with fixed dimension presets
- Disable denoise strength slider for external models (not applicable)
- Sync bbox aspect ratio changes back to paramsSlice for external models
- Initialize bbox dimensions when switching to an external model
This commit is contained in:
Alexander Eichhorn
2026-04-06 23:13:10 +02:00
parent 813a5e2c2e
commit c2016bcfb7
10 changed files with 153 additions and 36 deletions

View File

@@ -1490,6 +1490,7 @@
"copyImage": "Copy Image",
"denoisingStrength": "Denoising Strength",
"disabledNoRasterContent": "Disabled (No Raster Content)",
"disabledNotSupported": "Not supported by model",
"downloadImage": "Download Image",
"general": "General",
"guidance": "Guidance",

View File

@@ -4,9 +4,11 @@ import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/c
import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { loraIsEnabledChanged } from 'features/controlLayers/store/lorasSlice';
import {
aspectRatioIdChanged,
kleinQwen3EncoderModelSelected,
kleinVaeModelSelected,
modelChanged,
resolutionPresetSelected,
setZImageScheduler,
syncedToOptimalDimension,
vaeSelected,
@@ -24,7 +26,7 @@ import {
selectBboxModelBase,
selectCanvasSlice,
} from 'features/controlLayers/store/selectors';
import { getEntityIdentifier, isFlux2ReferenceImageConfig } from 'features/controlLayers/store/types';
import { getEntityIdentifier, isAspectRatioID, isFlux2ReferenceImageConfig } from 'features/controlLayers/store/types';
import {
initialFlux2ReferenceImage,
initialFluxKontextReferenceImage,
@@ -46,7 +48,7 @@ import {
selectZImageDiffusersModels,
} from 'services/api/hooks/modelsByType';
import type { FLUXKontextModelConfig, FLUXReduxModelConfig, IPAdapterModelConfig } from 'services/api/types';
import { isFluxKontextModelConfig, isFluxReduxModelConfig } from 'services/api/types';
import { isExternalApiModelConfig, isFluxKontextModelConfig, isFluxReduxModelConfig } from 'services/api/types';
const log = logger('models');
@@ -352,6 +354,34 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
dispatch(bboxSyncedToOptimalDimension());
}
}
// When switching to an external model, sync bbox to the model's first preset dimensions
if (newBase === 'external') {
const modelConfigsResult = selectModelConfigsQuery(getState());
if (modelConfigsResult.data) {
const newModelConfig = modelConfigsAdapterSelectors.selectById(modelConfigsResult.data, newModel.key);
if (newModelConfig && isExternalApiModelConfig(newModelConfig)) {
const { aspect_ratio_sizes, resolution_presets } = newModelConfig.capabilities;
if (resolution_presets && resolution_presets.length > 0) {
const firstPreset = resolution_presets[0]!;
dispatch(
resolutionPresetSelected({
imageSize: firstPreset.image_size,
aspectRatio: firstPreset.aspect_ratio,
width: firstPreset.width,
height: firstPreset.height,
})
);
} else if (aspect_ratio_sizes) {
const firstRatio = Object.keys(aspect_ratio_sizes)[0];
const firstSize = firstRatio ? aspect_ratio_sizes[firstRatio] : undefined;
if (firstRatio && firstSize && isAspectRatioID(firstRatio)) {
dispatch(aspectRatioIdChanged({ id: firstRatio, fixedSize: firstSize }));
}
}
}
}
}
},
});
};

View File

@@ -11,7 +11,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import WavyLine from 'common/components/WavyLine';
import { selectImg2imgStrength, setImg2imgStrength } from 'features/controlLayers/store/paramsSlice';
import { selectImg2imgStrength, selectIsExternal, setImg2imgStrength } from 'features/controlLayers/store/paramsSlice';
import { selectActiveRasterLayerEntities } from 'features/controlLayers/store/selectors';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -37,6 +37,7 @@ export const ParamDenoisingStrength = memo(() => {
const img2imgStrength = useAppSelector(selectImg2imgStrength);
const dispatch = useAppDispatch();
const hasRasterLayersWithContent = useAppSelector(selectHasRasterLayersWithContent);
const isExternal = useAppSelector(selectIsExternal);
const selectedModelConfig = useSelectedModelConfig();
const onChange = useCallback(
@@ -55,12 +56,16 @@ export const ParamDenoisingStrength = memo(() => {
// Denoising strength does nothing if there are no raster layers w/ content
return true;
}
if (isExternal) {
// External models don't support denoise strength - they handle img2img via prompt
return true;
}
if (selectedModelConfig && isFluxFillMainModelModelConfig(selectedModelConfig)) {
// Denoising strength is ignored by FLUX Fill, which is indicated by the variant being 'inpaint'
return true;
}
return false;
}, [hasRasterLayersWithContent, selectedModelConfig]);
}, [hasRasterLayersWithContent, isExternal, selectedModelConfig]);
return (
<FormControl isDisabled={isDisabled} p={1} justifyContent="space-between" h={8}>
@@ -96,7 +101,9 @@ export const ParamDenoisingStrength = memo(() => {
</>
) : (
<Flex alignItems="center">
<Badge opacity="0.6">{t('parameters.disabledNoRasterContent')}</Badge>
<Badge opacity="0.6">
{isExternal ? t('parameters.disabledNotSupported') : t('parameters.disabledNoRasterContent')}
</Badge>
</Flex>
)}
</FormControl>

View File

@@ -10,7 +10,7 @@ import {
getPrefixedId,
} from 'features/controlLayers/konva/util';
import { selectBboxOverlay } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectModel } from 'features/controlLayers/store/paramsSlice';
import { selectHasFixedDimensionSizes, selectModel } from 'features/controlLayers/store/paramsSlice';
import { selectBbox } from 'features/controlLayers/store/selectors';
import type { Coordinate, Rect, Tool } from 'features/controlLayers/store/types';
import Konva from 'konva';
@@ -191,6 +191,9 @@ export class CanvasBboxToolModule extends CanvasModuleBase {
// Listen for the model changing - some model types constraint the bbox to a certain size or aspect ratio.
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectModel, this.render));
// Listen for fixed dimension sizes changes - external models may lock bbox resizing
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectHasFixedDimensionSizes, this.render));
// Update on busy state changes
this.subscriptions.add(this.manager.$isBusy.listen(this.render));
@@ -246,6 +249,10 @@ export class CanvasBboxToolModule extends CanvasModuleBase {
if (tool !== 'bbox') {
return NO_ANCHORS;
}
// External models with fixed dimension presets don't allow free bbox resizing
if (this.manager.stateApi.runSelector(selectHasFixedDimensionSizes)) {
return NO_ANCHORS;
}
return ALL_ANCHORS;
};

View File

@@ -7,7 +7,7 @@ import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMul
import { merge } from 'es-toolkit/compat';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { canvasReset } from 'features/controlLayers/store/actions';
import { modelChanged } from 'features/controlLayers/store/paramsSlice';
import { aspectRatioIdChanged, modelChanged, resolutionPresetSelected } from 'features/controlLayers/store/paramsSlice';
import {
selectAllEntities,
selectAllEntitiesOfType,
@@ -31,6 +31,7 @@ import type {
RgbColor,
SimpleAdjustmentsConfig,
} from 'features/controlLayers/store/types';
import { isAspectRatioID } from 'features/controlLayers/store/types';
import {
calculateNewSize,
getScaledBoundingBoxDimensions,
@@ -1279,21 +1280,31 @@ const slice = createSlice({
state.bbox.aspectRatio.isLocked = !state.bbox.aspectRatio.isLocked;
syncScaledSize(state);
},
bboxAspectRatioIdChanged: (state, action: PayloadAction<{ id: AspectRatioID }>) => {
const { id } = action.payload;
bboxAspectRatioIdChanged: (
state,
action: PayloadAction<{ id: AspectRatioID; fixedSize?: { width: number; height: number } }>
) => {
const { id, fixedSize } = action.payload;
state.bbox.aspectRatio.id = id;
if (id === 'Free') {
state.bbox.aspectRatio.isLocked = false;
} else {
state.bbox.aspectRatio.isLocked = true;
state.bbox.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio;
const { width, height } = calculateNewSize(
state.bbox.aspectRatio.value,
state.bbox.rect.width * state.bbox.rect.height,
state.bbox.modelBase
);
state.bbox.rect.width = width;
state.bbox.rect.height = height;
if (fixedSize) {
// External models provide fixed dimensions for each aspect ratio
state.bbox.aspectRatio.value = fixedSize.width / fixedSize.height;
state.bbox.rect.width = fixedSize.width;
state.bbox.rect.height = fixedSize.height;
} else {
state.bbox.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio;
const { width, height } = calculateNewSize(
state.bbox.aspectRatio.value,
state.bbox.rect.width * state.bbox.rect.height,
state.bbox.modelBase
);
state.bbox.rect.width = width;
state.bbox.rect.height = height;
}
}
syncScaledSize(state);
@@ -1744,6 +1755,29 @@ const slice = createSlice({
syncScaledSize(state);
}
});
// Sync bbox when external model resolution preset is selected (aspect_ratio_sizes)
builder.addCase(aspectRatioIdChanged, (state, action) => {
const { id, fixedSize } = action.payload;
// Only sync when fixedSize is provided (external models with aspect_ratio_sizes)
if (fixedSize) {
state.bbox.rect.width = fixedSize.width;
state.bbox.rect.height = fixedSize.height;
state.bbox.aspectRatio.value = fixedSize.width / fixedSize.height;
state.bbox.aspectRatio.id = id;
state.bbox.aspectRatio.isLocked = true;
syncScaledSize(state);
}
});
// Sync bbox when external model resolution preset is selected (resolution_presets)
builder.addCase(resolutionPresetSelected, (state, action) => {
const { width, height, aspectRatio } = action.payload;
state.bbox.rect.width = width;
state.bbox.rect.height = height;
state.bbox.aspectRatio.value = width / height;
state.bbox.aspectRatio.id = isAspectRatioID(aspectRatio) ? aspectRatio : 'Free';
state.bbox.aspectRatio.isLocked = true;
syncScaledSize(state);
});
},
});

View File

@@ -636,19 +636,42 @@ export const zLoRA = z.object({
});
export type LoRA = z.infer<typeof zLoRA>;
export const zAspectRatioID = z.enum(['Free', '21:9', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16', '9:21']);
export const zAspectRatioID = z.enum([
'Free',
'8:1',
'4:1',
'21:9',
'16:9',
'3:2',
'5:4',
'4:3',
'1:1',
'3:4',
'4:5',
'2:3',
'9:16',
'1:4',
'9:21',
'1:8',
]);
export type AspectRatioID = z.infer<typeof zAspectRatioID>;
export const isAspectRatioID = (v: unknown): v is AspectRatioID => zAspectRatioID.safeParse(v).success;
export const ASPECT_RATIO_MAP: Record<Exclude<AspectRatioID, 'Free'>, { ratio: number; inverseID: AspectRatioID }> = {
'8:1': { ratio: 8 / 1, inverseID: '1:8' },
'4:1': { ratio: 4 / 1, inverseID: '1:4' },
'21:9': { ratio: 21 / 9, inverseID: '9:21' },
'16:9': { ratio: 16 / 9, inverseID: '9:16' },
'3:2': { ratio: 3 / 2, inverseID: '2:3' },
'5:4': { ratio: 5 / 4, inverseID: '4:5' },
'4:3': { ratio: 4 / 3, inverseID: '4:3' },
'1:1': { ratio: 1, inverseID: '1:1' },
'3:4': { ratio: 3 / 4, inverseID: '4:3' },
'4:5': { ratio: 4 / 5, inverseID: '5:4' },
'2:3': { ratio: 2 / 3, inverseID: '3:2' },
'9:16': { ratio: 9 / 16, inverseID: '16:9' },
'1:4': { ratio: 1 / 4, inverseID: '4:1' },
'9:21': { ratio: 9 / 21, inverseID: '21:9' },
'1:8': { ratio: 1 / 8, inverseID: '8:1' },
};
const zAspectRatioConfig = z.object({

View File

@@ -6,7 +6,6 @@ import { type ModelIdentifierField, zImageField } from 'features/nodes/types/com
import { Graph } from 'features/nodes/util/graph/generation/Graph';
import {
getOriginalAndScaledSizesForOtherModes,
getOriginalAndScaledSizesForTextToImage,
selectCanvasOutputFields,
} from 'features/nodes/util/graph/graphBuilderUtils';
import {
@@ -110,16 +109,15 @@ export const buildExternalGraph = async (arg: GraphBuilderArg): Promise<GraphBui
}
}
if (generationMode === 'txt2img') {
const { scaledSize } = getOriginalAndScaledSizesForTextToImage(state);
externalNode.width = scaledSize.width;
externalNode.height = scaledSize.height;
} else {
// External models require specific dimensions matching their supported presets.
// Always use params dimensions (from selected preset) for the API width/height.
externalNode.width = params.dimensions.width;
externalNode.height = params.dimensions.height;
if (generationMode !== 'txt2img') {
assert(manager, 'Canvas manager is required for img2img/inpaint');
const canvasSettings = selectCanvasSettingsSlice(state);
const { scaledSize, rect } = getOriginalAndScaledSizesForOtherModes(state);
externalNode.width = scaledSize.width;
externalNode.height = scaledSize.height;
const { rect } = getOriginalAndScaledSizesForOtherModes(state);
const rasterAdapters = manager.compositor.getVisibleAdaptersOfType('raster_layer');
const initImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, rect, {

View File

@@ -3,11 +3,16 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { bboxAspectRatioIdChanged } from 'features/controlLayers/store/canvasSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectAllowedAspectRatioIDs } from 'features/controlLayers/store/paramsSlice';
import {
aspectRatioIdChanged,
selectAllowedAspectRatioIDs,
selectAspectRatioSizes,
selectHasFixedDimensionSizes,
} from 'features/controlLayers/store/paramsSlice';
import { selectAspectRatioID } from 'features/controlLayers/store/selectors';
import { isAspectRatioID, zAspectRatioID } from 'features/controlLayers/store/types';
import type { ChangeEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
@@ -17,20 +22,27 @@ export const BboxAspectRatioSelect = memo(() => {
const id = useAppSelector(selectAspectRatioID);
const isStaging = useCanvasIsStaging();
const allowedAspectRatios = useAppSelector(selectAllowedAspectRatioIDs);
const options = allowedAspectRatios ?? zAspectRatioID.options;
const aspectRatioSizes = useAppSelector(selectAspectRatioSizes);
const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
const options = useMemo(() => allowedAspectRatios ?? zAspectRatioID.options, [allowedAspectRatios]);
const onChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => {
if (!isAspectRatioID(e.target.value)) {
return;
}
dispatch(bboxAspectRatioIdChanged({ id: e.target.value }));
const fixedSize = aspectRatioSizes?.[e.target.value] ?? undefined;
dispatch(bboxAspectRatioIdChanged({ id: e.target.value, fixedSize }));
// For external models with fixed sizes, also sync to params so buildExternalGraph uses correct dimensions
if (fixedSize) {
dispatch(aspectRatioIdChanged({ id: e.target.value, fixedSize }));
}
},
[dispatch]
[dispatch, aspectRatioSizes]
);
return (
<FormControl isDisabled={isStaging}>
<FormControl isDisabled={isStaging || hasFixedSizes}>
<InformationalPopover feature="paramAspect">
<FormLabel>{t('parameters.aspect')}</FormLabel>
</InformationalPopover>

View File

@@ -1,7 +1,8 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { bboxDimensionsSwapped } from 'features/controlLayers/store/canvasSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectHasFixedDimensionSizes } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsDownUpBold } from 'react-icons/pi';
@@ -10,6 +11,7 @@ export const BboxSwapDimensionsButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isStaging = useCanvasIsStaging();
const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
const onClick = useCallback(() => {
dispatch(bboxDimensionsSwapped());
}, [dispatch]);
@@ -21,7 +23,7 @@ export const BboxSwapDimensionsButton = memo(() => {
variant="ghost"
size="sm"
icon={<PiArrowsDownUpBold />}
isDisabled={isStaging}
isDisabled={isStaging || hasFixedSizes}
/>
);
});

View File

@@ -1,6 +1,9 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectHasFixedDimensionSizes } from 'features/controlLayers/store/paramsSlice';
export const useIsBboxSizeLocked = () => {
const isStaging = useCanvasIsStaging();
return isStaging;
const hasFixedSizes = useAppSelector(selectHasFixedDimensionSizes);
return isStaging || hasFixedSizes;
};