feat(ui): generate tab has separate w/h/aspect

This commit is contained in:
psychedelicious
2025-07-05 23:17:57 +10:00
parent 0a737ced44
commit 4925694dc1
15 changed files with 509 additions and 25 deletions

View File

@@ -3,7 +3,7 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import { bboxSyncedToOptimalDimension } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
import { modelChanged, vaeSelected } from 'features/controlLayers/store/paramsSlice';
import { modelChanged, syncedToOptimalDimension, vaeSelected } from 'features/controlLayers/store/paramsSlice';
import { selectBboxModelBase } from 'features/controlLayers/store/selectors';
import { modelSelected } from 'features/parameters/store/actions';
import { zParameterModel } from 'features/parameters/types/parameterSchemas';
@@ -71,9 +71,16 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
}
dispatch(modelChanged({ model: newModel, previousModel: state.params.model }));
const modelBase = selectBboxModelBase(state);
if (!selectIsStaging(state) && modelBase !== state.params.model?.base) {
dispatch(bboxSyncedToOptimalDimension());
if (modelBase !== state.params.model?.base) {
// Sync generate tab settings whenever the model base changes
dispatch(syncedToOptimalDimension());
if (!selectIsStaging(state)) {
// Canvas tab only syncs if not staging
dispatch(bboxSyncedToOptimalDimension());
}
}
},
});

View File

@@ -494,6 +494,12 @@ export const selectRefinerScheduler = createParamsSelector((params) => params.re
export const selectRefinerStart = createParamsSelector((params) => params.refinerStart);
export const selectRefinerSteps = createParamsSelector((params) => params.refinerSteps);
export const selectWidth = createParamsSelector((params) => params.dimensions.rect.width);
export const selectHeight = createParamsSelector((params) => params.dimensions.rect.height);
export const selectAspectRatioID = createParamsSelector((params) => params.dimensions.aspectRatio.id);
export const selectAspectRatioValue = createParamsSelector((params) => params.dimensions.aspectRatio.value);
export const selectAspectRatioIsLocked = createParamsSelector((params) => params.dimensions.aspectRatio.isLocked);
export const selectMainModelConfig = createSelector(
selectModelConfigsQuery,
selectParamsSlice,

View File

@@ -0,0 +1,39 @@
import type { FormLabelProps } from '@invoke-ai/ui-library';
import { Flex, FormControlGroup } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { DimensionsAspectRatioSelect } from './DimensionsAspectRatioSelect';
import { DimensionsHeight } from './DimensionsHeight';
import { DimensionsLockAspectRatioButton } from './DimensionsLockAspectRatioButton';
import { DimensionsPreview } from './DimensionsPreview';
import { DimensionsSetOptimalSizeButton } from './DimensionsSetOptimalSizeButton';
import { DimensionsSwapButton } from './DimensionsSwapButton';
import { DimensionsWidth } from './DimensionsWidth';
export const Dimensions = memo(() => {
return (
<Flex gap={4} alignItems="center">
<Flex gap={4} flexDirection="column" width="full">
<FormControlGroup formLabelProps={formLabelProps}>
<Flex gap={4}>
<DimensionsAspectRatioSelect />
<DimensionsSwapButton />
<DimensionsLockAspectRatioButton />
<DimensionsSetOptimalSizeButton />
</Flex>
<DimensionsWidth />
<DimensionsHeight />
</FormControlGroup>
</Flex>
<Flex w="108px" h="108px" flexShrink={0} flexGrow={0} alignItems="center" justifyContent="center">
<DimensionsPreview />
</Flex>
</Flex>
);
});
Dimensions.displayName = 'Dimensions';
const formLabelProps: FormLabelProps = {
minW: 10,
};

View File

@@ -0,0 +1,73 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import {
aspectRatioIdChanged,
selectAspectRatioID,
selectIsChatGPT4o,
selectIsFluxKontext,
selectIsImagen3,
selectIsImagen4,
} from 'features/controlLayers/store/paramsSlice';
import {
isAspectRatioID,
zAspectRatioID,
zChatGPT4oAspectRatioID,
zFluxKontextAspectRatioID,
zImagen3AspectRatioID,
} 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 DimensionsAspectRatioSelect = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const id = useAppSelector(selectAspectRatioID);
const isImagen3 = useAppSelector(selectIsImagen3);
const isChatGPT4o = useAppSelector(selectIsChatGPT4o);
const isImagen4 = useAppSelector(selectIsImagen4);
const isFluxKontext = useAppSelector(selectIsFluxKontext);
const options = useMemo(() => {
// Imagen3 and ChatGPT4o have different aspect ratio options, and do not support freeform sizes
if (isImagen3 || isImagen4) {
return zImagen3AspectRatioID.options;
}
if (isChatGPT4o) {
return zChatGPT4oAspectRatioID.options;
}
if (isFluxKontext) {
return zFluxKontextAspectRatioID.options;
}
// All other models
return zAspectRatioID.options;
}, [isImagen3, isChatGPT4o, isImagen4, isFluxKontext]);
const onChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => {
if (!isAspectRatioID(e.target.value)) {
return;
}
dispatch(aspectRatioIdChanged({ id: e.target.value }));
},
[dispatch]
);
return (
<FormControl>
<InformationalPopover feature="paramAspect">
<FormLabel>{t('parameters.aspect')}</FormLabel>
</InformationalPopover>
<Select size="sm" value={id} onChange={onChange} cursor="pointer" iconSize="0.75rem" icon={<PiCaretDownBold />}>
{options.map((ratio) => (
<option key={ratio} value={ratio}>
{ratio}
</option>
))}
</Select>
</FormControl>
);
});
DimensionsAspectRatioSelect.displayName = 'DimensionsAspectRatioSelect';

View File

@@ -0,0 +1,58 @@
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 { selectGridSize, selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { selectHeightConfig } from 'features/system/store/configSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const DimensionsHeight = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const optimalDimension = useAppSelector(selectOptimalDimension);
const height = useAppSelector(selectHeight);
const config = useAppSelector(selectHeightConfig);
const gridSize = useAppSelector(selectGridSize);
const onChange = useCallback(
(v: number) => {
dispatch(heightChanged({ height: v }));
},
[dispatch]
);
const marks = useMemo(
() => [config.sliderMin, optimalDimension, config.sliderMax],
[config.sliderMin, config.sliderMax, optimalDimension]
);
return (
<FormControl>
<InformationalPopover feature="paramHeight">
<FormLabel>{t('parameters.height')}</FormLabel>
</InformationalPopover>
<CompositeSlider
value={height}
defaultValue={optimalDimension}
onChange={onChange}
min={config.sliderMin}
max={config.sliderMax}
step={config.coarseStep}
fineStep={gridSize}
marks={marks}
/>
<CompositeNumberInput
value={height}
defaultValue={optimalDimension}
onChange={onChange}
min={config.numberInputMin}
max={config.numberInputMax}
step={config.coarseStep}
fineStep={gridSize}
/>
</FormControl>
);
});
DimensionsHeight.displayName = 'DimensionsHeight';

View File

@@ -0,0 +1,32 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { aspectRatioLockToggled, selectAspectRatioIsLocked } from 'features/controlLayers/store/paramsSlice';
import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiLockSimpleFill, PiLockSimpleOpenBold } from 'react-icons/pi';
export const DimensionsLockAspectRatioButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isLocked = useAppSelector(selectAspectRatioIsLocked);
const isApiModel = useIsApiModel();
const onClick = useCallback(() => {
dispatch(aspectRatioLockToggled());
}, [dispatch]);
return (
<IconButton
tooltip={t('parameters.lockAspectRatio')}
aria-label={t('parameters.lockAspectRatio')}
onClick={onClick}
variant={isLocked ? 'outline' : 'ghost'}
size="sm"
icon={isLocked ? <PiLockSimpleFill /> : <PiLockSimpleOpenBold />}
isDisabled={isApiModel}
/>
);
});
DimensionsLockAspectRatioButton.displayName = 'DimensionsLockAspectRatioButton';

View File

@@ -0,0 +1,71 @@
import { Flex, Grid, GridItem } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectAspectRatioValue, selectHeight, selectWidth } from 'features/controlLayers/store/paramsSlice';
import { memo, useMemo } from 'react';
import { useMeasure } from 'react-use';
export const DimensionsPreview = memo(() => {
const bboxWidth = useAppSelector(selectWidth);
const bboxHeight = useAppSelector(selectHeight);
const aspectRatioValue = useAppSelector(selectAspectRatioValue);
const [ref, dims] = useMeasure<HTMLDivElement>();
const previewBoxSize = useMemo(() => {
if (!dims) {
return { width: 0, height: 0 };
}
let width = bboxWidth;
let height = bboxHeight;
if (bboxWidth > bboxHeight) {
width = dims.width;
height = width / aspectRatioValue;
} else {
height = dims.height;
width = height * aspectRatioValue;
}
return { width, height };
}, [dims, bboxWidth, bboxHeight, aspectRatioValue]);
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center" ref={ref}>
<Flex
position="relative"
borderRadius="base"
borderColor="base.600"
borderWidth="3px"
width={`${previewBoxSize.width}px`}
height={`${previewBoxSize.height}px`}
alignItems="center"
justifyContent="center"
>
<Grid
borderRadius="base"
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
gridTemplateColumns="1fr 1fr 1fr"
gridTemplateRows="1fr 1fr 1fr"
gap="1px"
bg="base.700"
>
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
</Grid>
</Flex>
</Flex>
);
});
DimensionsPreview.displayName = 'DimensionsPreview';

View File

@@ -0,0 +1,53 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectHeight, selectWidth, sizeOptimized } from 'features/controlLayers/store/paramsSlice';
import { selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel';
import { getIsSizeTooLarge, getIsSizeTooSmall } from 'features/parameters/util/optimalDimension';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiSparkleFill } from 'react-icons/pi';
export const DimensionsSetOptimalSizeButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isApiModel = useIsApiModel();
const width = useAppSelector(selectWidth);
const height = useAppSelector(selectHeight);
const optimalDimension = useAppSelector(selectOptimalDimension);
const isSizeTooSmall = useMemo(
() => getIsSizeTooSmall(width, height, optimalDimension),
[height, width, optimalDimension]
);
const isSizeTooLarge = useMemo(
() => getIsSizeTooLarge(width, height, optimalDimension),
[height, width, optimalDimension]
);
const onClick = useCallback(() => {
dispatch(sizeOptimized());
}, [dispatch]);
const tooltip = useMemo(() => {
if (isSizeTooSmall) {
return t('parameters.setToOptimalSizeTooSmall');
}
if (isSizeTooLarge) {
return t('parameters.setToOptimalSizeTooLarge');
}
return t('parameters.setToOptimalSize');
}, [isSizeTooLarge, isSizeTooSmall, t]);
return (
<IconButton
tooltip={tooltip}
aria-label={t('parameters.setToOptimalSize')}
onClick={onClick}
variant="ghost"
size="sm"
icon={<PiSparkleFill />}
colorScheme={isSizeTooSmall || isSizeTooLarge ? 'warning' : 'base'}
isDisabled={isApiModel}
/>
);
});
DimensionsSetOptimalSizeButton.displayName = 'DimensionsSetOptimalSizeButton';

View File

@@ -0,0 +1,26 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { dimensionsSwapped } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsDownUpBold } from 'react-icons/pi';
export const DimensionsSwapButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(dimensionsSwapped());
}, [dispatch]);
return (
<IconButton
tooltip={t('parameters.swapDimensions')}
aria-label={t('parameters.swapDimensions')}
onClick={onClick}
variant="ghost"
size="sm"
icon={<PiArrowsDownUpBold />}
/>
);
});
DimensionsSwapButton.displayName = 'DimensionsSwapButton';

View File

@@ -0,0 +1,60 @@
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 { selectGridSize, selectOptimalDimension } from 'features/controlLayers/store/selectors';
import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel';
import { selectWidthConfig } from 'features/system/store/configSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const DimensionsWidth = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const width = useAppSelector(selectWidth);
const optimalDimension = useAppSelector(selectOptimalDimension);
const config = useAppSelector(selectWidthConfig);
const isApiModel = useIsApiModel();
const gridSize = useAppSelector(selectGridSize);
const onChange = useCallback(
(v: number) => {
dispatch(widthChanged({ width: v }));
},
[dispatch]
);
const marks = useMemo(
() => [config.sliderMin, optimalDimension, config.sliderMax],
[config.sliderMax, config.sliderMin, optimalDimension]
);
return (
<FormControl isDisabled={isApiModel}>
<InformationalPopover feature="paramWidth">
<FormLabel>{t('parameters.width')}</FormLabel>
</InformationalPopover>
<CompositeSlider
value={width}
onChange={onChange}
defaultValue={optimalDimension}
min={config.sliderMin}
max={config.sliderMax}
step={config.coarseStep}
fineStep={gridSize}
marks={marks}
/>
<CompositeNumberInput
value={width}
onChange={onChange}
defaultValue={optimalDimension}
min={config.numberInputMin}
max={config.numberInputMax}
step={config.coarseStep}
fineStep={gridSize}
/>
</FormControl>
);
});
DimensionsWidth.displayName = 'Dimensions';

View File

@@ -46,7 +46,7 @@ const scalingLabelProps: FormLabelProps = {
minW: '4.5rem',
};
export const ImageSettingsAccordion = memo(() => {
export const CanvasTabImageSettingsAccordion = memo(() => {
const { t } = useTranslation();
const badges = useAppSelector(selectBadges);
const scaleMethod = useAppSelector(selectScaleMethod);
@@ -99,4 +99,4 @@ export const ImageSettingsAccordion = memo(() => {
);
});
ImageSettingsAccordion.displayName = 'ImageSettingsAccordion';
CanvasTabImageSettingsAccordion.displayName = 'CanvasTabImageSettingsAccordion';

View File

@@ -0,0 +1,75 @@
import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import {
selectAspectRatioID,
selectAspectRatioIsLocked,
selectHeight,
selectShouldRandomizeSeed,
selectWidth,
} from 'features/controlLayers/store/paramsSlice';
import { Dimensions } from 'features/parameters/components/Dimensions/Dimensions';
import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed';
import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel';
import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const selectBadges = createMemoizedSelector(
[selectWidth, selectHeight, selectAspectRatioID, selectAspectRatioIsLocked, selectShouldRandomizeSeed],
(width, height, aspectRatioID, aspectRatioIsLocked, shouldRandomizeSeed) => {
const badges: string[] = [];
badges.push(`${width}×${height}`);
badges.push(aspectRatioID);
if (aspectRatioIsLocked) {
badges.push('locked');
}
if (!shouldRandomizeSeed) {
badges.push('Manual Seed');
}
if (badges.length === 0) {
return EMPTY_ARRAY;
}
badges;
}
);
export const GenerateTabImageSettingsAccordion = memo(() => {
const { t } = useTranslation();
const badges = useAppSelector(selectBadges);
const { isOpen: isOpenAccordion, onToggle: onToggleAccordion } = useStandaloneAccordionToggle({
id: 'image-settings-generate-tab',
defaultIsOpen: true,
});
const isApiModel = useIsApiModel();
return (
<StandaloneAccordion
label={t('accordions.image.title')}
badges={badges}
isOpen={isOpenAccordion}
onToggle={onToggleAccordion}
>
<Flex
px={4}
pt={4}
pb={isApiModel ? 4 : 0}
w="full"
h="full"
flexDir="column"
data-testid="image-settings-accordion"
>
<Dimensions />
{!isApiModel && <ParamSeed py={3} />}
</Flex>
</StandaloneAccordion>
);
});
GenerateTabImageSettingsAccordion.displayName = 'GenerateTabImageSettingsAccordion';

View File

@@ -1,16 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ImageSettingsAccordion } from './ImageSettingsAccordion';
const meta: Meta<typeof ImageSettingsAccordion> = {
title: 'Feature/ImageSettingsAccordion',
tags: ['autodocs'],
component: ImageSettingsAccordion,
};
export default meta;
type Story = StoryObj<typeof ImageSettingsAccordion>;
export const Default: Story = {
render: () => <ImageSettingsAccordion />,
};

View File

@@ -8,7 +8,7 @@ import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel';
import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion';
import { CompositingSettingsAccordion } from 'features/settingsAccordions/components/CompositingSettingsAccordion/CompositingSettingsAccordion';
import { GenerationSettingsAccordion } from 'features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion';
import { ImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion';
import { CanvasTabImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/CanvasTabImageSettingsAccordion';
import { RefinerSettingsAccordion } from 'features/settingsAccordions/components/RefinerSettingsAccordion/RefinerSettingsAccordion';
import { StylePresetMenu } from 'features/stylePresets/components/StylePresetMenu';
import { StylePresetMenuTrigger } from 'features/stylePresets/components/StylePresetMenuTrigger';
@@ -44,7 +44,7 @@ export const ParametersPanelCanvas = memo(() => {
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
<Prompts />
<ImageSettingsAccordion />
<CanvasTabImageSettingsAccordion />
<GenerationSettingsAccordion />
{!isApiModel && <CompositingSettingsAccordion />}
{isSDXL && <RefinerSettingsAccordion />}

View File

@@ -7,7 +7,7 @@ import { Prompts } from 'features/parameters/components/Prompts/Prompts';
import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel';
import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion';
import { GenerationSettingsAccordion } from 'features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion';
import { ImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion';
import { GenerateTabImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/GenerateTabImageSettingsAccordion';
import { RefinerSettingsAccordion } from 'features/settingsAccordions/components/RefinerSettingsAccordion/RefinerSettingsAccordion';
import { StylePresetMenu } from 'features/stylePresets/components/StylePresetMenu';
import { StylePresetMenuTrigger } from 'features/stylePresets/components/StylePresetMenuTrigger';
@@ -43,7 +43,7 @@ export const ParametersPanelGenerate = memo(() => {
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
<Prompts />
<ImageSettingsAccordion />
<GenerateTabImageSettingsAccordion />
<GenerationSettingsAccordion />
{isSDXL && <RefinerSettingsAccordion />}
{!isCogview4 && !isApiModel && <AdvancedSettingsAccordion />}