mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user