feat(ui): make ref images croppable

This commit is contained in:
psychedelicious
2025-09-17 13:24:58 +10:00
parent aac979e9a4
commit e0d7a401f3
28 changed files with 368 additions and 92 deletions

View File

@@ -12,7 +12,13 @@ import {
} from 'features/controlLayers/store/paramsSlice';
import { refImageModelChanged, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier, isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types';
import {
getEntityIdentifier,
isFLUXReduxConfig,
isIPAdapterConfig,
isRegionalGuidanceFLUXReduxConfig,
isRegionalGuidanceIPAdapterConfig,
} from 'features/controlLayers/store/types';
import { zModelIdentifierField } from 'features/nodes/types/common';
import { modelSelected } from 'features/parameters/store/actions';
import {
@@ -252,7 +258,7 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => {
entity.referenceImages.forEach(({ id: referenceImageId, config }) => {
if (!isIPAdapterConfig(config)) {
if (!isRegionalGuidanceIPAdapterConfig(config)) {
return;
}
@@ -295,7 +301,7 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => {
selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => {
entity.referenceImages.forEach(({ id: referenceImageId, config }) => {
if (!isFLUXReduxConfig(config)) {
if (!isRegionalGuidanceFLUXReduxConfig(config)) {
return;
}

View File

@@ -1,12 +1,16 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { objectEquals } from '@observ33r/object-equals';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
import { bboxSizeOptimized, bboxSizeRecalled } from 'features/controlLayers/store/canvasSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { sizeOptimized, sizeRecalled } from 'features/controlLayers/store/paramsSlice';
import type { ImageWithDims } from 'features/controlLayers/store/types';
import type { CroppableImageWithDims } from 'features/controlLayers/store/types';
import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { Editor } from 'features/cropper/lib/editor';
import { cropImageModalApi } from 'features/cropper/store';
import type { setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { DndImage } from 'features/dnd/DndImage';
@@ -14,14 +18,14 @@ import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiRulerBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PiArrowCounterClockwiseBold, PiCropBold, PiRulerBold } from 'react-icons/pi';
import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { $isConnected } from 'services/events/stores';
type Props<T extends typeof setGlobalReferenceImageDndTarget | typeof setRegionalGuidanceReferenceImageDndTarget> = {
image: ImageWithDims | null;
onChangeImage: (imageDTO: ImageDTO | null) => void;
image: CroppableImageWithDims | null;
onChangeImage: (croppableImage: CroppableImageWithDims | null) => void;
dndTarget: T;
dndTargetData: ReturnType<T['getData']>;
};
@@ -38,20 +42,28 @@ export const RefImageImage = memo(
const isConnected = useStore($isConnected);
const tab = useAppSelector(selectActiveTab);
const isStaging = useCanvasIsStaging();
const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken);
const imageWithDims = image?.crop?.image ?? image?.original.image ?? null;
const croppedImageDTOReq = useGetImageDTOQuery(image?.crop?.image?.image_name ?? skipToken);
const originalImageDTOReq = useGetImageDTOQuery(image?.original.image.image_name ?? skipToken);
const [uploadImage] = useUploadImageMutation();
const originalImageDTO = originalImageDTOReq.currentData;
const croppedImageDTO = croppedImageDTOReq.currentData;
const imageDTO = croppedImageDTO ?? originalImageDTO;
const handleResetControlImage = useCallback(() => {
onChangeImage(null);
}, [onChangeImage]);
useEffect(() => {
if (isConnected && isError) {
if ((isConnected && croppedImageDTOReq.isError) || originalImageDTOReq.isError) {
handleResetControlImage();
}
}, [handleResetControlImage, isError, isConnected]);
}, [handleResetControlImage, isConnected, croppedImageDTOReq.isError, originalImageDTOReq.isError]);
const onUpload = useCallback(
(imageDTO: ImageDTO) => {
onChangeImage(imageDTO);
onChangeImage(imageDTOToCroppableImage(imageDTO));
},
[onChangeImage]
);
@@ -70,13 +82,67 @@ export const RefImageImage = memo(
}
}, [imageDTO, isStaging, store, tab]);
const edit = useCallback(() => {
if (!originalImageDTO) {
return;
}
// We will create a new editor instance each time the user wants to edit
const editor = new Editor();
// When the user applies the crop, we will upload the cropped image and store the applied crop box so if the user
// re-opens the editor they see the same crop
const onApplyCrop = async () => {
const box = editor.getCropBox();
if (objectEquals(box, image?.crop?.box)) {
// If the box hasn't changed, don't do anything
return;
}
if (!box || objectEquals(box, { x: 0, y: 0, width: originalImageDTO.width, height: originalImageDTO.height })) {
// There is a crop applied but it is the whole iamge - revert to original image
onChangeImage(imageDTOToCroppableImage(originalImageDTO));
return;
}
const blob = await editor.exportImage('blob');
const file = new File([blob], 'image.png', { type: 'image/png' });
const newCroppedImageDTO = await uploadImage({
file,
is_intermediate: true,
image_category: 'user',
}).unwrap();
onChangeImage(
imageDTOToCroppableImage(originalImageDTO, {
image: imageDTOToImageWithDims(newCroppedImageDTO),
box,
ratio: editor.getCropAspectRatio(),
})
);
};
const onReady = async () => {
const initial = image?.crop ? { cropBox: image.crop.box, aspectRatio: image.crop.ratio } : undefined;
// Load the image into the editor and open the modal once it's ready
await editor.loadImage(originalImageDTO.image_url, initial);
};
cropImageModalApi.open({ editor, onApplyCrop, onReady });
}, [image?.crop, onChangeImage, originalImageDTO, uploadImage]);
return (
<Flex position="relative" w="full" h="full" alignItems="center" data-error={!imageDTO && !image?.image_name}>
<Flex
position="relative"
w="full"
h="full"
alignItems="center"
data-error={!imageDTO && !imageWithDims?.image_name}
>
{!imageDTO && (
<UploadImageIconButton
w="full"
h="full"
isError={!imageDTO && !image?.image_name}
isError={!imageDTO && !imageWithDims?.image_name}
onUpload={onUpload}
fontSize={36}
/>
@@ -99,6 +165,15 @@ export const RefImageImage = memo(
isDisabled={!imageDTO || (tab === 'canvas' && isStaging)}
/>
</Flex>
<Flex position="absolute" flexDir="column" top={2} insetInlineStart={2} gap={1}>
<DndImageIcon
onClick={edit}
icon={<PiCropBold size={16} />}
tooltip={t('common.crop')}
isDisabled={!imageDTO}
/>
</Flex>
</>
)}
<DndDropTarget dndTarget={dndTarget} dndTargetData={dndTargetData} label={t('gallery.drop')} />

View File

@@ -13,7 +13,7 @@ import {
selectRefImageEntityIds,
selectSelectedRefEntityId,
} from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
@@ -92,7 +92,7 @@ const AddRefImageDropTargetAndButton = memo(() => {
({
onUpload: (imageDTO: ImageDTO) => {
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
config.image = imageDTOToCroppableImage(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
},
allowMultiple: false,

View File

@@ -1,6 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, Icon, IconButton, Image, Skeleton, Text, Tooltip } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { round } from 'es-toolkit/compat';
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
@@ -15,7 +14,7 @@ import { isIPAdapterConfig } from 'features/controlLayers/store/types';
import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useImageDTOFromCroppableImage } from 'services/api/endpoints/images';
import { RefImageWarningTooltipContent } from './RefImageWarningTooltipContent';
@@ -72,7 +71,8 @@ export const RefImagePreview = memo(() => {
const selectedEntityId = useAppSelector(selectSelectedRefEntityId);
const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen);
const [showWeightDisplay, setShowWeightDisplay] = useState(false);
const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken);
const imageDTO = useImageDTOFromCroppableImage(entity.config.image);
const sx = useMemo(() => {
if (!isIPAdapterConfig(entity.config)) {
@@ -145,7 +145,7 @@ export const RefImagePreview = memo(() => {
overflow="hidden"
>
<Image
src={imageDTO?.thumbnail_url}
src={imageDTO?.image_url}
objectFit="contain"
aspectRatio="1/1"
height={imageDTO?.height}

View File

@@ -30,6 +30,7 @@ import {
} from 'features/controlLayers/store/refImagesSlice';
import type {
CLIPVisionModelV2,
CroppableImageWithDims,
FLUXReduxImageInfluence as FLUXReduxImageInfluenceType,
IPMethodV2,
} from 'features/controlLayers/store/types';
@@ -42,7 +43,6 @@ import type {
ChatGPT4oModelConfig,
FLUXKontextModelConfig,
FLUXReduxModelConfig,
ImageDTO,
IPAdapterModelConfig,
} from 'services/api/types';
@@ -104,15 +104,19 @@ const RefImageSettingsContent = memo(() => {
);
const onChangeImage = useCallback(
(imageDTO: ImageDTO | null) => {
dispatch(refImageImageChanged({ id, imageDTO }));
(croppableImage: CroppableImageWithDims | null) => {
dispatch(refImageImageChanged({ id, croppableImage }));
},
[dispatch, id]
);
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
() => setGlobalReferenceImageDndTarget.getData({ id }, config.image?.image_name),
[id, config.image?.image_name]
() =>
setGlobalReferenceImageDndTarget.getData(
{ id },
config.image?.crop?.image.image_name ?? config.image?.original.image.image_name
),
[id, config.image?.crop?.image.image_name, config.image?.original.image.image_name]
);
const isFLUX = useAppSelector(selectIsFLUX);

View File

@@ -6,7 +6,6 @@ import { FLUXReduxImageInfluence } from 'features/controlLayers/components/commo
import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel';
import { Weight } from 'features/controlLayers/components/common/Weight';
import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod';
import { RefImageImage } from 'features/controlLayers/components/RefImage/RefImageImage';
import { RegionalGuidanceIPAdapterSettingsEmptyState } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState';
import { RegionalReferenceImageModel } from 'features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
@@ -37,6 +36,8 @@ import { PiBoundingBoxBold, PiXBold } from 'react-icons/pi';
import type { FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
import { RegionalGuidanceRefImageImage } from './RegionalGuidanceRefImageImage';
type Props = {
referenceImageId: string;
};
@@ -114,7 +115,7 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
{ entityIdentifier, referenceImageId },
config.image?.image_name
),
[entityIdentifier, config.image?.image_name, referenceImageId]
[entityIdentifier, config.image, referenceImageId]
);
const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId);
@@ -170,7 +171,7 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
</Flex>
)}
<Flex alignItems="center" justifyContent="center" h={32} w={32} aspectRatio="1/1" flexGrow={1}>
<RefImageImage
<RegionalGuidanceRefImageImage
image={config.image}
onChangeImage={onChangeImage}
dndTarget={setRegionalGuidanceReferenceImageDndTarget}

View File

@@ -0,0 +1,103 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
import { bboxSizeOptimized, bboxSizeRecalled } from 'features/controlLayers/store/canvasSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { sizeOptimized, sizeRecalled } from 'features/controlLayers/store/paramsSlice';
import type { ImageWithDims } from 'features/controlLayers/store/types';
import type { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { DndImage } from 'features/dnd/DndImage';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiRulerBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { $isConnected } from 'services/events/stores';
type Props = {
image: ImageWithDims | null;
onChangeImage: (imageDTO: ImageDTO | null) => void;
dndTarget: typeof setRegionalGuidanceReferenceImageDndTarget;
dndTargetData: ReturnType<(typeof setRegionalGuidanceReferenceImageDndTarget)['getData']>;
};
export const RegionalGuidanceRefImageImage = memo(({ image, onChangeImage, dndTarget, dndTargetData }: Props) => {
const { t } = useTranslation();
const store = useAppStore();
const isConnected = useStore($isConnected);
const tab = useAppSelector(selectActiveTab);
const isStaging = useCanvasIsStaging();
const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken);
const handleResetControlImage = useCallback(() => {
onChangeImage(null);
}, [onChangeImage]);
useEffect(() => {
if (isConnected && isError) {
handleResetControlImage();
}
}, [handleResetControlImage, isError, isConnected]);
const onUpload = useCallback(
(imageDTO: ImageDTO) => {
onChangeImage(imageDTO);
},
[onChangeImage]
);
const recallSizeAndOptimize = useCallback(() => {
if (!imageDTO || (tab === 'canvas' && isStaging)) {
return;
}
const { width, height } = imageDTO;
if (tab === 'canvas') {
store.dispatch(bboxSizeRecalled({ width, height }));
store.dispatch(bboxSizeOptimized());
} else if (tab === 'generate') {
store.dispatch(sizeRecalled({ width, height }));
store.dispatch(sizeOptimized());
}
}, [imageDTO, isStaging, store, tab]);
return (
<Flex position="relative" w="full" h="full" alignItems="center" data-error={!imageDTO && !image?.image_name}>
{!imageDTO && (
<UploadImageIconButton
w="full"
h="full"
isError={!imageDTO && !image?.image_name}
onUpload={onUpload}
fontSize={36}
/>
)}
{imageDTO && (
<>
<DndImage imageDTO={imageDTO} borderRadius="base" borderWidth={1} borderStyle="solid" w="full" />
<Flex position="absolute" flexDir="column" top={2} insetInlineEnd={2} gap={1}>
<DndImageIcon
onClick={handleResetControlImage}
icon={<PiArrowCounterClockwiseBold size={16} />}
tooltip={t('common.reset')}
/>
</Flex>
<Flex position="absolute" flexDir="column" bottom={2} insetInlineEnd={2} gap={1}>
<DndImageIcon
onClick={recallSizeAndOptimize}
icon={<PiRulerBold size={16} />}
tooltip={t('parameters.useSize')}
isDisabled={!imageDTO || (tab === 'canvas' && isStaging)}
/>
</Flex>
</>
)}
<DndDropTarget dndTarget={dndTarget} dndTargetData={dndTargetData} label={t('gallery.drop')} />
</Flex>
);
});
RegionalGuidanceRefImageImage.displayName = 'RegionalGuidanceRefImageImage';

View File

@@ -30,6 +30,7 @@ import type {
FluxKontextReferenceImageConfig,
Gemini2_5ReferenceImageConfig,
IPAdapterConfig,
RegionalGuidanceIPAdapterConfig,
T2IAdapterConfig,
} from 'features/controlLayers/store/types';
import {
@@ -38,6 +39,7 @@ import {
initialFluxKontextReferenceImage,
initialGemini2_5ReferenceImage,
initialIPAdapter,
initialRegionalGuidanceIPAdapter,
initialT2IAdapter,
} from 'features/controlLayers/store/util';
import { zModelIdentifierField } from 'features/nodes/types/common';
@@ -125,7 +127,7 @@ export const getDefaultRefImageConfig = (
return config;
};
export const getDefaultRegionalGuidanceRefImageConfig = (getState: AppGetState): IPAdapterConfig => {
export const getDefaultRegionalGuidanceRefImageConfig = (getState: AppGetState): RegionalGuidanceIPAdapterConfig => {
// Regional guidance ref images do not support ChatGPT-4o, so we always return the IP Adapter config.
const state = getState();
@@ -138,7 +140,7 @@ export const getDefaultRegionalGuidanceRefImageConfig = (getState: AppGetState):
const modelConfig = ipAdapterModelConfigs.find((m) => m.base === base);
// Clone the initial IP Adapter config and set the model if available.
const config = deepClone(initialIPAdapter);
const config = deepClone(initialRegionalGuidanceIPAdapter);
if (modelConfig) {
config.model = zModelIdentifierField.parse(modelConfig);

View File

@@ -32,7 +32,12 @@ import type {
RefImageState,
RegionalGuidanceRefImageState,
} from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util';
import {
imageDTOToCroppableImage,
imageDTOToImageObject,
imageDTOToImageWithDims,
initialControlNet,
} from 'features/controlLayers/store/util';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import type { BoardId } from 'features/gallery/store/types';
import { Graph } from 'features/nodes/util/graph/generation/Graph';
@@ -209,7 +214,7 @@ export const useNewGlobalReferenceImageFromBbox = () => {
const overrides: Partial<RefImageState> = {
config: {
...getDefaultRefImageConfig(getState),
image: imageDTOToImageWithDims(imageDTO),
image: imageDTOToCroppableImage(imageDTO),
},
};
dispatch(refImageAdded({ overrides }));
@@ -312,7 +317,7 @@ export const usePullBboxIntoGlobalReferenceImage = (id: string) => {
const arg = useMemo<UseSaveCanvasArg>(() => {
const onSave = (imageDTO: ImageDTO, _: Rect) => {
dispatch(refImageImageChanged({ id, imageDTO }));
dispatch(refImageImageChanged({ id, croppableImage: imageDTOToCroppableImage(imageDTO) }));
};
return {

View File

@@ -82,10 +82,10 @@ import {
IMAGEN_ASPECT_RATIOS,
isChatGPT4oAspectRatioID,
isFluxKontextAspectRatioID,
isFLUXReduxConfig,
isGemini2_5AspectRatioID,
isImagenAspectRatioID,
isIPAdapterConfig,
isRegionalGuidanceFLUXReduxConfig,
isRegionalGuidanceIPAdapterConfig,
zCanvasState,
} from './types';
import {
@@ -99,6 +99,7 @@ import {
initialControlNet,
initialFLUXRedux,
initialIPAdapter,
initialRegionalGuidanceIPAdapter,
initialT2IAdapter,
makeDefaultRasterLayerAdjustments,
} from './util';
@@ -804,7 +805,7 @@ const slice = createSlice({
if (!entity) {
return;
}
const config = { id: referenceImageId, config: deepClone(initialIPAdapter) };
const config = { id: referenceImageId, config: deepClone(initialRegionalGuidanceIPAdapter) };
merge(config, overrides);
entity.referenceImages.push(config);
},
@@ -847,7 +848,7 @@ const slice = createSlice({
if (!referenceImage) {
return;
}
if (!isIPAdapterConfig(referenceImage.config)) {
if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) {
return;
}
@@ -864,7 +865,7 @@ const slice = createSlice({
if (!referenceImage) {
return;
}
if (!isIPAdapterConfig(referenceImage.config)) {
if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) {
return;
}
referenceImage.config.beginEndStepPct = beginEndStepPct;
@@ -880,7 +881,7 @@ const slice = createSlice({
if (!referenceImage) {
return;
}
if (!isIPAdapterConfig(referenceImage.config)) {
if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) {
return;
}
referenceImage.config.method = method;
@@ -899,7 +900,7 @@ const slice = createSlice({
if (!referenceImage) {
return;
}
if (!isFLUXReduxConfig(referenceImage.config)) {
if (!isRegionalGuidanceFLUXReduxConfig(referenceImage.config)) {
return;
}
@@ -928,7 +929,7 @@ const slice = createSlice({
return;
}
if (isIPAdapterConfig(referenceImage.config) && isFluxReduxModelConfig(modelConfig)) {
if (isRegionalGuidanceIPAdapterConfig(referenceImage.config) && isFluxReduxModelConfig(modelConfig)) {
// Switching from ip_adapter to flux_redux
referenceImage.config = {
...initialFLUXRedux,
@@ -938,7 +939,7 @@ const slice = createSlice({
return;
}
if (isFLUXReduxConfig(referenceImage.config) && isIPAdapterModelConfig(modelConfig)) {
if (isRegionalGuidanceFLUXReduxConfig(referenceImage.config) && isIPAdapterModelConfig(modelConfig)) {
// Switching from flux_redux to ip_adapter
referenceImage.config = {
...initialIPAdapter,
@@ -948,7 +949,7 @@ const slice = createSlice({
return;
}
if (isIPAdapterConfig(referenceImage.config)) {
if (isRegionalGuidanceIPAdapterConfig(referenceImage.config)) {
referenceImage.config.model = zModelIdentifierField.parse(modelConfig);
// Ensure that the IP Adapter model is compatible with the CLIP Vision model
@@ -971,7 +972,7 @@ const slice = createSlice({
if (!referenceImage) {
return;
}
if (!isIPAdapterConfig(referenceImage.config)) {
if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) {
return;
}
referenceImage.config.clipVisionModel = clipVisionModel;

View File

@@ -6,13 +6,16 @@ import type { RootState } from 'app/store/store';
import type { SliceConfig } from 'app/store/types';
import { clamp } from 'es-toolkit/compat';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { FLUXReduxImageInfluence, RefImagesState } from 'features/controlLayers/store/types';
import type {
CroppableImageWithDims,
FLUXReduxImageInfluence,
RefImagesState,
} from 'features/controlLayers/store/types';
import { zModelIdentifierField } from 'features/nodes/types/common';
import type {
ChatGPT4oModelConfig,
FLUXKontextModelConfig,
FLUXReduxModelConfig,
ImageDTO,
IPAdapterModelConfig,
} from 'services/api/types';
import { assert } from 'tsafe';
@@ -22,7 +25,6 @@ import type { CLIPVisionModelV2, IPMethodV2, RefImageState } from './types';
import { getInitialRefImagesState, isFLUXReduxConfig, isIPAdapterConfig, zRefImagesState } from './types';
import {
getReferenceImageState,
imageDTOToImageWithDims,
initialChatGPT4oReferenceImage,
initialFluxKontextReferenceImage,
initialFLUXRedux,
@@ -65,13 +67,13 @@ const slice = createSlice({
state.entities.push(...entities);
}
},
refImageImageChanged: (state, action: PayloadActionWithId<{ imageDTO: ImageDTO | null }>) => {
const { id, imageDTO } = action.payload;
refImageImageChanged: (state, action: PayloadActionWithId<{ croppableImage: CroppableImageWithDims | null }>) => {
const { id, croppableImage } = action.payload;
const entity = selectRefImageEntity(state, id);
if (!entity) {
return;
}
entity.config.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null;
entity.config.image = croppableImage;
},
refImageIPAdapterMethodChanged: (state, action: PayloadActionWithId<{ method: IPMethodV2 }>) => {
const { id, method } = action.payload;

View File

@@ -43,16 +43,37 @@ const zCropBox = z.object({
width: z.number().positive(),
height: z.number().positive(),
});
export const zCroppableImageWithDims = z.object({
original: zImageWithDims,
crop: z
.object({
box: zCropBox,
ratio: z.number().gt(0).nullable(),
image: zImageWithDims,
})
.optional(),
});
// This new schema is an extension of zImageWithDims, with an optional crop field.
//
// When we added cropping support to certain entities (e.g. Ref Images, video Starting Frame Image), we changed
// their schemas from using zImageWithDims to this new schema. To support loading pre-existing entities that
// were created before cropping was supported, we can use zod's preprocess to transform old data into the new format.
// Its essentially a data migration step.
//
// This parsing happens currently in two places:
// - Recalling metadata.
// - Loading/rehydrating persisted client state from storage.
export const zCroppableImageWithDims = z.preprocess(
(val) => {
try {
const imageWithDims = zImageWithDims.parse(val);
const migrated = { original: { image: deepClone(imageWithDims) } };
return migrated;
} catch {
return val;
}
},
z.object({
original: z.object({ image: zImageWithDims }),
crop: z
.object({
box: zCropBox,
ratio: z.number().gt(0).nullable(),
image: zImageWithDims,
})
.optional(),
})
);
export type CroppableImageWithDims = z.infer<typeof zCroppableImageWithDims>;
const zImageWithDimsDataURL = z.object({
@@ -253,7 +274,7 @@ export type CanvasObjectState = z.infer<typeof zCanvasObjectState>;
const zIPAdapterConfig = z.object({
type: z.literal('ip_adapter'),
image: zImageWithDims.nullable(),
image: zCroppableImageWithDims.nullable(),
model: zModelIdentifierField.nullable(),
weight: z.number().gte(-1).lte(2),
beginEndStepPct: zBeginEndStepPct,
@@ -262,21 +283,39 @@ const zIPAdapterConfig = z.object({
});
export type IPAdapterConfig = z.infer<typeof zIPAdapterConfig>;
const zRegionalGuidanceIPAdapterConfig = z.object({
type: z.literal('ip_adapter'),
image: zImageWithDims.nullable(),
model: zModelIdentifierField.nullable(),
weight: z.number().gte(-1).lte(2),
beginEndStepPct: zBeginEndStepPct,
method: zIPMethodV2,
clipVisionModel: zCLIPVisionModelV2,
});
export type RegionalGuidanceIPAdapterConfig = z.infer<typeof zRegionalGuidanceIPAdapterConfig>;
const zFLUXReduxImageInfluence = z.enum(['lowest', 'low', 'medium', 'high', 'highest']);
export const isFLUXReduxImageInfluence = (v: unknown): v is FLUXReduxImageInfluence =>
zFLUXReduxImageInfluence.safeParse(v).success;
export type FLUXReduxImageInfluence = z.infer<typeof zFLUXReduxImageInfluence>;
const zFLUXReduxConfig = z.object({
type: z.literal('flux_redux'),
image: zImageWithDims.nullable(),
image: zCroppableImageWithDims.nullable(),
model: zModelIdentifierField.nullable(),
imageInfluence: zFLUXReduxImageInfluence.default('highest'),
});
export type FLUXReduxConfig = z.infer<typeof zFLUXReduxConfig>;
const zRegionalGuidanceFLUXReduxConfig = z.object({
type: z.literal('flux_redux'),
image: zImageWithDims.nullable(),
model: zModelIdentifierField.nullable(),
imageInfluence: zFLUXReduxImageInfluence.default('highest'),
});
type RegionalGuidanceFLUXReduxConfig = z.infer<typeof zRegionalGuidanceFLUXReduxConfig>;
const zChatGPT4oReferenceImageConfig = z.object({
type: z.literal('chatgpt_4o_reference_image'),
image: zImageWithDims.nullable(),
image: zCroppableImageWithDims.nullable(),
/**
* TODO(psyche): Technically there is no model for ChatGPT 4o reference images - it's just a field in the API call.
* But we use a model drop down to switch between different ref image types, so there needs to be a model here else
@@ -288,14 +327,14 @@ export type ChatGPT4oReferenceImageConfig = z.infer<typeof zChatGPT4oReferenceIm
const zGemini2_5ReferenceImageConfig = z.object({
type: z.literal('gemini_2_5_reference_image'),
image: zImageWithDims.nullable(),
image: zCroppableImageWithDims.nullable(),
model: zModelIdentifierField.nullable(),
});
export type Gemini2_5ReferenceImageConfig = z.infer<typeof zGemini2_5ReferenceImageConfig>;
const zFluxKontextReferenceImageConfig = z.object({
type: z.literal('flux_kontext_reference_image'),
image: zImageWithDims.nullable(),
image: zCroppableImageWithDims.nullable(),
model: zModelIdentifierField.nullable(),
});
export type FluxKontextReferenceImageConfig = z.infer<typeof zFluxKontextReferenceImageConfig>;
@@ -325,6 +364,7 @@ export const isIPAdapterConfig = (config: RefImageState['config']): config is IP
export const isFLUXReduxConfig = (config: RefImageState['config']): config is FLUXReduxConfig =>
config.type === 'flux_redux';
export const isChatGPT4oReferenceImageConfig = (
config: RefImageState['config']
): config is ChatGPT4oReferenceImageConfig => config.type === 'chatgpt_4o_reference_image';
@@ -344,10 +384,18 @@ const zFill = z.object({ style: zFillStyle, color: zRgbColor });
const zRegionalGuidanceRefImageState = z.object({
id: zId,
config: z.discriminatedUnion('type', [zIPAdapterConfig, zFLUXReduxConfig]),
config: z.discriminatedUnion('type', [zRegionalGuidanceIPAdapterConfig, zRegionalGuidanceFLUXReduxConfig]),
});
export type RegionalGuidanceRefImageState = z.infer<typeof zRegionalGuidanceRefImageState>;
export const isRegionalGuidanceIPAdapterConfig = (
config: RegionalGuidanceRefImageState['config']
): config is RegionalGuidanceIPAdapterConfig => config.type === 'ip_adapter';
export const isRegionalGuidanceFLUXReduxConfig = (
config: RegionalGuidanceRefImageState['config']
): config is RegionalGuidanceFLUXReduxConfig => config.type === 'flux_redux';
const zCanvasRegionalGuidanceState = zCanvasEntityBase.extend({
type: z.literal('regional_guidance'),
position: zCoordinate,

View File

@@ -18,6 +18,7 @@ import type {
IPAdapterConfig,
RasterLayerAdjustments,
RefImageState,
RegionalGuidanceIPAdapterConfig,
RgbColor,
T2IAdapterConfig,
} from 'features/controlLayers/store/types';
@@ -52,7 +53,7 @@ export const imageDTOToCroppableImage = (
): CroppableImageWithDims => {
const { image_name, width, height } = originalImageDTO;
const val: CroppableImageWithDims = {
original: { image_name, width, height },
original: { image: { image_name, width, height } },
};
if (crop) {
val.crop = deepClone(crop);
@@ -95,6 +96,15 @@ export const initialIPAdapter: IPAdapterConfig = {
clipVisionModel: 'ViT-H',
weight: 1,
};
export const initialRegionalGuidanceIPAdapter: RegionalGuidanceIPAdapterConfig = {
type: 'ip_adapter',
image: null,
model: null,
beginEndStepPct: [0, 1],
method: 'full',
clipVisionModel: 'ViT-H',
weight: 1,
};
export const initialFLUXRedux: FLUXReduxConfig = {
type: 'flux_redux',
image: null,

View File

@@ -236,8 +236,11 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image
const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, image_name: string) => {
selectReferenceImageEntities(state).forEach((entity) => {
if (entity.config.image?.image_name === image_name) {
dispatch(refImageImageChanged({ id: entity.id, imageDTO: null }));
if (
entity.config.image?.original.image.image_name === image_name ||
entity.config.image?.crop?.image.image_name === image_name
) {
dispatch(refImageImageChanged({ id: entity.id, croppableImage: null }));
}
});
};
@@ -284,7 +287,10 @@ export const getImageUsage = (
const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name;
const isReferenceImage = refImages.entities.some(({ config }) => config.image?.image_name === image_name);
const isReferenceImage = refImages.entities.some(
({ config }) =>
config.image?.original.image.image_name === image_name || config.image?.crop?.image.image_name === image_name
);
const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) =>
objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name)

View File

@@ -4,7 +4,7 @@ import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerH
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types';
import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common';
import type { BoardId } from 'features/gallery/store/types';
import {
@@ -211,7 +211,7 @@ export const addGlobalReferenceImageDndTarget: DndTarget<
handler: ({ sourceData, dispatch, getState }) => {
const { imageDTO } = sourceData.payload;
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
config.image = imageDTOToCroppableImage(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
},
};

View File

@@ -2,7 +2,7 @@ import { MenuItem } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/storeHooks';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
@@ -17,7 +17,7 @@ export const ContextMenuItemUseAsRefImage = memo(() => {
const onClickNewGlobalReferenceImageFromImage = useCallback(() => {
const { dispatch, getState } = store;
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
config.image = imageDTOToCroppableImage(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
toast({
id: 'SENT_TO_CANVAS',

View File

@@ -26,7 +26,12 @@ import type {
CanvasRasterLayerState,
CanvasRegionalGuidanceState,
} from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util';
import {
imageDTOToCroppableImage,
imageDTOToImageObject,
imageDTOToImageWithDims,
initialControlNet,
} from 'features/controlLayers/store/util';
import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import type { BoardId } from 'features/gallery/store/types';
@@ -44,7 +49,7 @@ import { assert } from 'tsafe';
export const setGlobalReferenceImage = (arg: { imageDTO: ImageDTO; id: string; dispatch: AppDispatch }) => {
const { imageDTO, id, dispatch } = arg;
dispatch(refImageImageChanged({ id, imageDTO }));
dispatch(refImageImageChanged({ id, croppableImage: imageDTOToCroppableImage(imageDTO) }));
};
export const setRegionalGuidanceReferenceImage = (arg: {

View File

@@ -975,7 +975,7 @@ const RefImages: CollectionMetadataHandler<RefImageState[]> = {
for (const refImage of parsed) {
if (refImage.config.image) {
await throwIfImageDoesNotExist(refImage.config.image.image_name, store);
await throwIfImageDoesNotExist(refImage.config.image.original.image.image_name, store);
}
if (refImage.config.model) {
await throwIfModelDoesNotExist(refImage.config.model.key, store);

View File

@@ -87,7 +87,7 @@ const addFLUXRedux = (id: string, ipAdapter: FLUXReduxConfig, g: Graph, collecto
type: 'flux_redux',
redux_model: fluxReduxModel,
image: {
image_name: image.image_name,
image_name: image.crop?.image.image_name ?? image.original.image.image_name,
},
...IMAGE_INFLUENCE_TO_SETTINGS[ipAdapter.imageInfluence ?? 'highest'],
});

View File

@@ -58,7 +58,7 @@ const addIPAdapter = (id: string, ipAdapter: IPAdapterConfig, g: Graph, collecto
begin_step_percent: beginEndStepPct[0],
end_step_percent: beginEndStepPct[1],
image: {
image_name: image.image_name,
image_name: image.crop?.image.image_name ?? image.original.image.image_name,
},
});
} else {
@@ -77,7 +77,7 @@ const addIPAdapter = (id: string, ipAdapter: IPAdapterConfig, g: Graph, collecto
begin_step_percent: beginEndStepPct[0],
end_step_percent: beginEndStepPct[1],
image: {
image_name: image.image_name,
image_name: image.crop?.image.image_name ?? image.original.image.image_name,
},
});
}

View File

@@ -5,8 +5,8 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import {
type CanvasRegionalGuidanceState,
isFLUXReduxConfig,
isIPAdapterConfig,
isRegionalGuidanceFLUXReduxConfig,
isRegionalGuidanceIPAdapterConfig,
type Rect,
} from 'features/controlLayers/store/types';
import { getRegionalGuidanceWarnings } from 'features/controlLayers/store/validators';
@@ -279,7 +279,7 @@ export const addRegions = async ({
}
for (const { id, config } of region.referenceImages) {
if (isIPAdapterConfig(config)) {
if (isRegionalGuidanceIPAdapterConfig(config)) {
assert(!isFLUX, 'Regional IP adapters are not supported for FLUX.');
result.addedIPAdapters++;
@@ -304,7 +304,7 @@ export const addRegions = async ({
// Connect the mask to the conditioning
g.addEdge(maskToTensor, 'mask', ipAdapterNode, 'mask');
g.addEdge(ipAdapterNode, 'ip_adapter', ipAdapterCollect, 'item');
} else if (isFLUXReduxConfig(config)) {
} else if (isRegionalGuidanceFLUXReduxConfig(config)) {
assert(isFLUX, 'Regional FLUX Redux requires FLUX.');
assert(fluxReduxCollect !== null, 'FLUX Redux collector is required.');
result.addedFLUXReduxes++;

View File

@@ -50,7 +50,7 @@ export const buildChatGPT4oGraph = async (arg: GraphBuilderArg): Promise<GraphBu
for (const entity of validRefImages) {
assert(entity.config.image, 'Image is required for reference image');
reference_images.push({
image_name: entity.config.image.image_name,
image_name: entity.config.image.crop?.image.image_name ?? entity.config.image.original.image.image_name,
});
}
}

View File

@@ -61,7 +61,7 @@ export const buildFluxKontextGraph = (arg: GraphBuilderArg): GraphBuilderReturn
aspect_ratio: aspectRatio.id,
prompt_upsampling: true,
input_image: {
image_name: firstImage.image_name,
image_name: firstImage.crop?.image.image_name ?? firstImage.original.image.image_name,
},
...selectCanvasOutputFields(state),
});

View File

@@ -45,7 +45,7 @@ export const buildGemini2_5Graph = (arg: GraphBuilderArg): GraphBuilderReturn =>
for (const entity of validRefImages) {
assert(entity.config.image, 'Image is required for reference image');
reference_images.push({
image_name: entity.config.image.image_name,
image_name: entity.config.image.crop?.image.image_name ?? entity.config.image.original.image.image_name,
});
}
}

View File

@@ -309,7 +309,7 @@ const getReasonsWhyCannotEnqueueVideoTab = (arg: {
reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') });
}
if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.original.image_name) {
if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.original.image.image_name) {
reasons.push({ content: i18n.t('parameters.invoke.noStartingFrameImage') });
}

View File

@@ -28,10 +28,10 @@ export const StartingFrameImage = () => {
const dispatch = useAppDispatch();
const requiresStartingFrame = useAppSelector(selectVideoModelRequiresStartingFrame);
const startingFrameImage = useAppSelector(selectStartingFrameImage);
const originalImageDTO = useImageDTO(startingFrameImage?.original.image_name);
const originalImageDTO = useImageDTO(startingFrameImage?.original.image.image_name);
const croppedImageDTO = useImageDTO(startingFrameImage?.crop?.image.image_name);
const videoAspectRatio = useAppSelector(selectVideoAspectRatio);
const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' });
const [uploadImage] = useUploadImageMutation();
const onReset = useCallback(() => {
dispatch(startingFrameImageChanged(null));

View File

@@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton';
@@ -23,7 +23,7 @@ export const LaunchpadAddStyleReference = memo((props: { extraAction?: () => voi
({
onUpload: (imageDTO: ImageDTO) => {
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
config.image = imageDTOToCroppableImage(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
props.extraAction?.();
},

View File

@@ -1,6 +1,7 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { $authToken } from 'app/store/nanostores/authToken';
import { getStore } from 'app/store/nanostores/store';
import type { CroppableImageWithDims } from 'features/controlLayers/store/types';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import type { components, paths } from 'services/api/schema';
import type {
@@ -593,3 +594,10 @@ export const useImageDTO = (imageName: string | null | undefined) => {
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
return imageDTO ?? null;
};
export const useImageDTOFromCroppableImage = (croppableImage: CroppableImageWithDims | null) => {
const { currentData: imageDTO } = useGetImageDTOQuery(
croppableImage?.crop?.image.image_name ?? croppableImage?.original.image.image_name ?? skipToken
);
return imageDTO ?? null;
};