diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 0ecb003fd62..f7a310fc321 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -104,6 +104,7 @@
"copy": "Copy",
"copyError": "$t(gallery.copy) Error",
"clipboard": "Clipboard",
+ "crop": "Crop",
"on": "On",
"off": "Off",
"or": "or",
@@ -1269,6 +1270,7 @@
"infillColorValue": "Fill Color",
"info": "Info",
"startingFrameImage": "Start Frame",
+ "startingFrameImageAspectRatioWarning": "Image aspect ratio does not match the video aspect ratio ({{videoAspectRatio}}). This could lead to unexpected cropping during video generation.",
"invoke": {
"addingImagesTo": "Adding images to",
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your account settings to upgrade.",
diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
index b6e64dc86c6..b5aec1dd561 100644
--- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
+++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
@@ -2,6 +2,7 @@ import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { CropImageModal } from 'features/cropper/components/CropImageModal';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
import { DeleteVideoModal } from 'features/deleteVideoModal/components/DeleteVideoModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
@@ -58,6 +59,7 @@ export const GlobalModalIsolator = memo(() => {
+
>
);
});
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
index 7a9fcc1c694..62f398b5ed8 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
@@ -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;
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx
index e0042de290d..a754f0e4da4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx
@@ -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 = {
- image: ImageWithDims | null;
- onChangeImage: (imageDTO: ImageDTO | null) => void;
+ image: CroppableImageWithDims | null;
+ onChangeImage: (croppableImage: CroppableImageWithDims | null) => void;
dndTarget: T;
dndTargetData: ReturnType;
};
@@ -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 (
-
+
{!imageDTO && (
@@ -99,6 +165,15 @@ export const RefImageImage = memo(
isDisabled={!imageDTO || (tab === 'canvas' && isStaging)}
/>
+
+
+ }
+ tooltip={t('common.crop')}
+ isDisabled={!imageDTO}
+ />
+
>
)}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx
index d5f39a111b8..6683e247b05 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx
@@ -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,
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx
index 4439e04ea13..0d9bd14955a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx
@@ -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"
>
{
);
const onChangeImage = useCallback(
- (imageDTO: ImageDTO | null) => {
- dispatch(refImageImageChanged({ id, imageDTO }));
+ (croppableImage: CroppableImageWithDims | null) => {
+ dispatch(refImageImageChanged({ id, croppableImage }));
},
[dispatch, id]
);
const dndTargetData = useMemo(
- () => 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);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx
index c49a5a1658e..402487bd39d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx
@@ -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
)}
- 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 (
+
+ {!imageDTO && (
+
+ )}
+ {imageDTO && (
+ <>
+
+
+ }
+ tooltip={t('common.reset')}
+ />
+
+
+ }
+ tooltip={t('parameters.useSize')}
+ isDisabled={!imageDTO || (tab === 'canvas' && isStaging)}
+ />
+
+ >
+ )}
+
+
+ );
+});
+
+RegionalGuidanceRefImageImage.displayName = 'RegionalGuidanceRefImageImage';
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index 75b424ed286..062937edcd0 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -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);
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts
index f164b1f2f16..6b089c4592b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts
@@ -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 = {
config: {
...getDefaultRefImageConfig(getState),
- image: imageDTOToImageWithDims(imageDTO),
+ image: imageDTOToCroppableImage(imageDTO),
},
};
dispatch(refImageAdded({ overrides }));
@@ -312,7 +317,7 @@ export const usePullBboxIntoGlobalReferenceImage = (id: string) => {
const arg = useMemo(() => {
const onSave = (imageDTO: ImageDTO, _: Rect) => {
- dispatch(refImageImageChanged({ id, imageDTO }));
+ dispatch(refImageImageChanged({ id, croppableImage: imageDTOToCroppableImage(imageDTO) }));
};
return {
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
index 8c8987d5462..ee1a9c6ba44 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
@@ -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;
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
index 539d94ac33a..e787d08fca0 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
@@ -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;
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index 1969fb77b64..3163bd85b2a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -37,6 +37,45 @@ export const zImageWithDims = z.object({
});
export type ImageWithDims = z.infer;
+const zCropBox = z.object({
+ x: z.number().min(0),
+ y: z.number().min(0),
+ width: z.number().positive(),
+ height: z.number().positive(),
+});
+// 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;
+
const zImageWithDimsDataURL = z.object({
dataURL: z.string(),
width: z.number().int().positive(),
@@ -235,7 +274,7 @@ export type CanvasObjectState = z.infer;
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,
@@ -244,21 +283,39 @@ const zIPAdapterConfig = z.object({
});
export type IPAdapterConfig = z.infer;
+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;
+
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;
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;
+const zRegionalGuidanceFLUXReduxConfig = z.object({
+ type: z.literal('flux_redux'),
+ image: zImageWithDims.nullable(),
+ model: zModelIdentifierField.nullable(),
+ imageInfluence: zFLUXReduxImageInfluence.default('highest'),
+});
+type RegionalGuidanceFLUXReduxConfig = z.infer;
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
@@ -270,14 +327,14 @@ export type ChatGPT4oReferenceImageConfig = z.infer;
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;
@@ -307,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';
@@ -326,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;
+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,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
index cb6e816e320..54b484e78ae 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
@@ -10,6 +10,7 @@ import type {
ChatGPT4oReferenceImageConfig,
ControlLoRAConfig,
ControlNetConfig,
+ CroppableImageWithDims,
FluxKontextReferenceImageConfig,
FLUXReduxConfig,
Gemini2_5ReferenceImageConfig,
@@ -17,6 +18,7 @@ import type {
IPAdapterConfig,
RasterLayerAdjustments,
RefImageState,
+ RegionalGuidanceIPAdapterConfig,
RgbColor,
T2IAdapterConfig,
} from 'features/controlLayers/store/types';
@@ -45,6 +47,21 @@ export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO)
height,
});
+export const imageDTOToCroppableImage = (
+ originalImageDTO: ImageDTO,
+ crop?: CroppableImageWithDims['crop']
+): CroppableImageWithDims => {
+ const { image_name, width, height } = originalImageDTO;
+ const val: CroppableImageWithDims = {
+ original: { image: { image_name, width, height } },
+ };
+ if (crop) {
+ val.crop = deepClone(crop);
+ }
+
+ return val;
+};
+
export const imageDTOToImageField = ({ image_name }: ImageDTO): ImageField => ({ image_name });
const DEFAULT_RG_MASK_FILL_COLORS: RgbColor[] = [
@@ -79,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,
diff --git a/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx b/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx
new file mode 100644
index 00000000000..0790608041b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx
@@ -0,0 +1,215 @@
+import {
+ Button,
+ ButtonGroup,
+ Divider,
+ Flex,
+ FormControl,
+ FormLabel,
+ Select,
+ Spacer,
+ Text,
+} from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import type { AspectRatioID } from 'features/controlLayers/store/types';
+import { ASPECT_RATIO_MAP, isAspectRatioID } from 'features/controlLayers/store/types';
+import type { CropBox } from 'features/cropper/lib/editor';
+import { cropImageModalApi, type CropImageModalState } from 'features/cropper/store';
+import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
+import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { useUploadImageMutation } from 'services/api/endpoints/images';
+import { objectEntries } from 'tsafe';
+
+type Props = {
+ editor: CropImageModalState['editor'];
+ onApplyCrop: CropImageModalState['onApplyCrop'];
+ onReady: CropImageModalState['onReady'];
+};
+
+const getAspectRatioString = (ratio: number | null): AspectRatioID => {
+ if (!ratio) {
+ return 'Free';
+ }
+ const entries = objectEntries(ASPECT_RATIO_MAP);
+ for (const [key, value] of entries) {
+ if (value.ratio === ratio) {
+ return key;
+ }
+ }
+ return 'Free';
+};
+
+export const CropImageEditor = memo(({ editor, onApplyCrop, onReady }: Props) => {
+ const containerRef = useRef(null);
+ const [zoom, setZoom] = useState(100);
+ const [cropBox, setCropBox] = useState(null);
+ const [aspectRatio, setAspectRatio] = useState('free');
+ const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
+
+ const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' });
+
+ const setup = useCallback(
+ async (container: HTMLDivElement) => {
+ editor.init(container);
+ editor.onZoomChange((zoom) => {
+ setZoom(zoom);
+ });
+ editor.onCropBoxChange((crop) => {
+ setCropBox(crop);
+ });
+ editor.onAspectRatioChange((ratio) => {
+ setAspectRatio(getAspectRatioString(ratio));
+ });
+ await onReady();
+ editor.fitToContainer();
+ },
+ [editor, onReady]
+ );
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+ setup(container);
+ const handleResize = () => {
+ editor.resize(container.clientWidth, container.clientHeight);
+ };
+
+ const resizeObserver = new ResizeObserver(handleResize);
+ resizeObserver.observe(container);
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [editor, setup]);
+
+ const handleAspectRatioChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newRatio = e.target.value;
+ if (!isAspectRatioID(newRatio)) {
+ return;
+ }
+ setAspectRatio(newRatio);
+
+ if (newRatio === 'Free') {
+ editor.setCropAspectRatio(null);
+ } else {
+ editor.setCropAspectRatio(ASPECT_RATIO_MAP[newRatio]?.ratio ?? null);
+ }
+ },
+ [editor]
+ );
+
+ const handleResetCrop = useCallback(() => {
+ editor.resetCrop();
+ }, [editor]);
+
+ const handleApplyCrop = useCallback(async () => {
+ await onApplyCrop();
+ cropImageModalApi.close();
+ }, [onApplyCrop]);
+
+ const handleCancelCrop = useCallback(() => {
+ cropImageModalApi.close();
+ }, []);
+
+ const handleExport = useCallback(async () => {
+ try {
+ const blob = await editor.exportImage('blob');
+ const file = new File([blob], 'image.png', { type: 'image/png' });
+
+ await uploadImage({
+ file,
+ is_intermediate: false,
+ image_category: 'user',
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ }).unwrap();
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('tainted')) {
+ alert(
+ 'Cannot export image: The image is from a different domain (CORS issue). To fix this:\n\n1. Load images from the same domain\n2. Use images from CORS-enabled sources\n3. Upload a local image file instead'
+ );
+ } else {
+ alert(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+ }, [autoAddBoardId, editor, uploadImage]);
+
+ const zoomIn = useCallback(() => {
+ editor.zoomIn();
+ }, [editor]);
+
+ const zoomOut = useCallback(() => {
+ editor.zoomOut();
+ }, [editor]);
+
+ const fitToContainer = useCallback(() => {
+ editor.fitToContainer();
+ }, [editor]);
+
+ const resetView = useCallback(() => {
+ editor.resetView();
+ }, [editor]);
+
+ return (
+
+
+
+ Aspect Ratio:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mouse wheel: Zoom
+
+ Space + Drag: Pan
+
+ Drag crop box or handles to adjust
+ {cropBox && (
+ <>
+
+
+ X: {Math.round(cropBox.x)}, Y: {Math.round(cropBox.y)}, Width: {Math.round(cropBox.width)}, Height:{' '}
+ {Math.round(cropBox.height)}
+
+ >
+ )}
+
+ Zoom: {Math.round(zoom * 100)}%
+
+
+ );
+});
+
+CropImageEditor.displayName = 'CropImageEditor';
diff --git a/invokeai/frontend/web/src/features/cropper/components/CropImageModal.tsx b/invokeai/frontend/web/src/features/cropper/components/CropImageModal.tsx
new file mode 100644
index 00000000000..03126c55ad1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/cropper/components/CropImageModal.tsx
@@ -0,0 +1,29 @@
+import { Modal, ModalBody, ModalContent, ModalHeader, ModalOverlay } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { cropImageModalApi } from 'features/cropper/store';
+import { memo } from 'react';
+
+import { CropImageEditor } from './CropImageEditor';
+
+export const CropImageModal = memo(() => {
+ const state = useStore(cropImageModalApi.$state);
+
+ if (!state) {
+ return null;
+ }
+
+ return (
+ // This modal is always open when this component is rendered
+
+
+
+ Crop Image
+
+
+
+
+
+ );
+});
+
+CropImageModal.displayName = 'CropImageModal';
diff --git a/invokeai/frontend/web/src/features/cropper/lib/editor.ts b/invokeai/frontend/web/src/features/cropper/lib/editor.ts
new file mode 100644
index 00000000000..6249e3bb255
--- /dev/null
+++ b/invokeai/frontend/web/src/features/cropper/lib/editor.ts
@@ -0,0 +1,1557 @@
+import { $crossOrigin } from 'app/store/nanostores/authToken';
+import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
+import Konva from 'konva';
+import type { KonvaEventObject } from 'konva/lib/Node';
+import { objectEntries } from 'tsafe';
+
+/**
+ * The position and size of a crop box.
+ */
+export type CropBox = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+/**
+ * The callbacks supported by the editor.
+ */
+type EditorCallbacks = {
+ onCropBoxChange: Set<(crop: Readonly) => void>;
+ onAspectRatioChange: Set<(ratio: number | null) => void>;
+ onZoomChange: Set<(zoom: number) => void>;
+};
+
+/**
+ * Crop box resize handle names.
+ */
+type HandleName = 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left' | 'top' | 'right' | 'bottom' | 'left';
+
+/**
+ * Crop box guide line names.
+ */
+type GuideName = 'left' | 'right' | 'top' | 'bottom';
+
+/**
+ * All the Konva objects used by the editor, organized by function and approximating the Konva node structures.
+ */
+type KonvaObjects = {
+ stage: Konva.Stage;
+ bg: {
+ layer: Konva.Layer;
+ rect: Konva.Rect;
+ };
+ image: {
+ layer: Konva.Layer;
+ image?: Konva.Image;
+ };
+ crop: {
+ layer: Konva.Layer;
+ overlay: {
+ group: Konva.Group;
+ full: Konva.Rect;
+ clear: Konva.Rect;
+ };
+ interaction: {
+ group: Konva.Group;
+ rect: Konva.Rect;
+ handles: Record;
+ guides: Record;
+ };
+ };
+};
+
+/**
+ * Valid editor output formats.
+ */
+type OutputFormat = 'canvas' | 'blob' | 'dataURL';
+
+/**
+ * Type helper mapping output format name to the actual data type.
+ */
+type OutputFormatToOutputMap = T extends 'canvas'
+ ? HTMLCanvasElement
+ : T extends 'blob'
+ ? Blob
+ : T extends 'dataURL'
+ ? string
+ : never;
+
+/**
+ * The editor's configurable parameters.
+ */
+type EditorConfig = {
+ /**
+ * The minimum size for the crop box. Applied to both width and height.
+ */
+ MIN_CROP_DIMENSION: number;
+
+ /**
+ * The zoom factor applied when zooming with the mouse wheel. A value of 1.1 means each wheel step zooms in/out by 10%.
+ */
+ ZOOM_WHEEL_FACTOR: number;
+
+ /**
+ * The zoom factor applied when zooming with buttons (e.g. the editor's zoomIn/zoomOut methods). A value of 1.2 means
+ * each button press zooms in/out by 20%.
+ */
+ ZOOM_BUTTON_FACTOR: number;
+
+ /**
+ * The size of the crop box resize handles. The handles do not scale with zoom; this is the size they will appear on screen.
+ */
+ CROP_HANDLE_SIZE: number;
+
+ /**
+ * The stroke width of the crop box resize handles. The stroke does not scale with zoom; this is the width it will appear on screen.
+ */
+ CROP_HANDLE_STROKE_WIDTH: number;
+
+ /**
+ * The fill color for the crop box resize handles.
+ */
+ CROP_HANDLE_FILL: string;
+
+ /**
+ * The stroke color for the crop box resize handles.
+ */
+ CROP_HANDLE_STROKE: string;
+
+ /**
+ * The stroke color for the group box guides.
+ */
+ CROP_GUIDE_STROKE: string;
+
+ /**
+ * The stroke width for the crop box guides. The stroke does not scale with zoom; this is the width it will appear on screen.
+ */
+ CROP_GUIDE_STROKE_WIDTH: number;
+
+ /**
+ * The fill color for the crop overlay (the darkened area outside the crop box).
+ */
+ CROP_OVERLAY_FILL_COLOR: string;
+
+ /**
+ * When fitting the image to the container, this padding factor is applied to ensure some space around the image.
+ */
+ FIT_TO_CONTAINER_PADDING_PCT: number;
+
+ /**
+ * When starting a new crop, the initial crop box will be this fraction of the image size.
+ */
+ DEFAULT_CROP_BOX_SCALE: number;
+
+ /**
+ * The minimum zoom (scale) for the stage.
+ */
+ ZOOM_MIN_PCT: number;
+
+ /**
+ * The maximum zoom (scale) for the stage.
+ */
+ ZOOM_MAX_PCT: number;
+};
+
+const DEFAULT_CONFIG: EditorConfig = {
+ MIN_CROP_DIMENSION: 64,
+ ZOOM_WHEEL_FACTOR: 1.1,
+ ZOOM_BUTTON_FACTOR: 1.2,
+ CROP_HANDLE_SIZE: 8,
+ CROP_HANDLE_STROKE_WIDTH: 1,
+ CROP_HANDLE_FILL: 'white',
+ CROP_HANDLE_STROKE: 'black',
+ CROP_GUIDE_STROKE: 'rgba(255, 255, 255, 0.5)',
+ CROP_GUIDE_STROKE_WIDTH: 1,
+ CROP_OVERLAY_FILL_COLOR: 'rgba(0, 0, 0, 0.8)',
+ FIT_TO_CONTAINER_PADDING_PCT: 0.9,
+ DEFAULT_CROP_BOX_SCALE: 0.8,
+ ZOOM_MIN_PCT: 0.1,
+ ZOOM_MAX_PCT: 10,
+};
+
+export class Editor {
+ private konva: KonvaObjects | null = null;
+ private originalImage: HTMLImageElement | null = null;
+ private config: EditorConfig = DEFAULT_CONFIG;
+
+ private aspectRatio: number | null = null;
+
+ private callbacks: EditorCallbacks = {
+ onCropBoxChange: new Set(),
+ onZoomChange: new Set(),
+ onAspectRatioChange: new Set(),
+ };
+
+ private cropBox: CropBox | null = null;
+
+ // State
+ private isPanning = false;
+ private lastPointerPosition: { x: number; y: number } | null = null;
+ private isSpacePressed = false;
+
+ private cleanupFunctions: Set<() => void> = new Set();
+
+ /**
+ * Initialize the editor inside the given container element.
+ * @param container The HTML element to contain the editor. It will be used as the Konva stage container.
+ * @param config Optional configuration overrides.
+ */
+ init = (container: HTMLDivElement, config?: Partial) => {
+ this.config = { ...this.config, ...config };
+
+ const stage = new Konva.Stage({
+ container: container,
+ width: container.clientWidth,
+ height: container.clientHeight,
+ });
+
+ const bg = this.createKonvaBgObjects();
+ const image = this.createKonvaImageObjects();
+ const crop = this.createKonvaCropObjects();
+
+ stage.add(bg.layer);
+ stage.add(image.layer);
+ stage.add(crop.layer);
+
+ this.konva = {
+ stage,
+ bg,
+ image,
+ crop,
+ };
+
+ this.setupListeners();
+ };
+
+ /**
+ * Create the Konva objects used for the background layer (checkerboard pattern).
+ */
+ private createKonvaBgObjects = (): KonvaObjects['bg'] => {
+ const layer = new Konva.Layer();
+ const rect = new Konva.Rect();
+ const image = new Image();
+ image.onload = () => {
+ rect.fillPatternImage(image);
+ this.updateKonvaBg();
+ };
+ image.src = TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL;
+ layer.add(rect);
+
+ return {
+ layer,
+ rect,
+ };
+ };
+
+ /**
+ * Create the Konva objects used for the image layer. Note that the Konva image node is created when an image is loaded.
+ */
+ private createKonvaImageObjects = (): KonvaObjects['image'] => {
+ const layer = new Konva.Layer();
+ return {
+ layer,
+ };
+ };
+
+ /**
+ * Create the Konva objects used for cropping (overlay and interaction).
+ */
+ private createKonvaCropObjects = (): KonvaObjects['crop'] => {
+ const layer = new Konva.Layer();
+ const overlay = this.createKonvaCropOverlayObjects();
+ const interaction = this.createKonvaCropInteractionObjects();
+ layer.add(overlay.group);
+ layer.add(interaction.group);
+ return {
+ layer,
+ overlay,
+ interaction,
+ };
+ };
+
+ /**
+ * Create the Konva objects used for the crop overlay (the darkened area outside the crop box).
+ *
+ * This includes a full rectangle covering the entire image and a rectangle matching the crop box which is used to
+ * "cut out" the crop area from the overlay using the 'destination-out' composite operation.
+ */
+ private createKonvaCropOverlayObjects = (): KonvaObjects['crop']['overlay'] => {
+ const group = new Konva.Group();
+ const full = new Konva.Rect({
+ fill: this.config.CROP_OVERLAY_FILL_COLOR,
+ });
+ const clear = new Konva.Rect({
+ fill: 'black',
+ globalCompositeOperation: 'destination-out',
+ });
+ group.add(full);
+ group.add(clear);
+ return {
+ group,
+ full,
+ clear,
+ };
+ };
+
+ /**
+ * Create the Konva objects used for crop interaction (the crop box, resize handles, and guides).
+ */
+ private createKonvaCropInteractionObjects = (): KonvaObjects['crop']['interaction'] => {
+ const group = new Konva.Group();
+
+ const rect = this.createKonvaCropInteractionRect();
+ const handles = {
+ 'top-left': this.createKonvaCropInteractionHandle('top-left'),
+ 'top-right': this.createKonvaCropInteractionHandle('top-right'),
+ 'bottom-right': this.createKonvaCropInteractionHandle('bottom-right'),
+ 'bottom-left': this.createKonvaCropInteractionHandle('bottom-left'),
+ top: this.createKonvaCropInteractionHandle('top'),
+ right: this.createKonvaCropInteractionHandle('right'),
+ bottom: this.createKonvaCropInteractionHandle('bottom'),
+ left: this.createKonvaCropInteractionHandle('left'),
+ };
+ const guides = {
+ left: this.createKonvaCropInteractionGuide('left'),
+ right: this.createKonvaCropInteractionGuide('right'),
+ top: this.createKonvaCropInteractionGuide('top'),
+ bottom: this.createKonvaCropInteractionGuide('bottom'),
+ };
+
+ group.add(rect);
+
+ for (const handle of Object.values(handles)) {
+ group.add(handle);
+ }
+ for (const guide of Object.values(guides)) {
+ group.add(guide);
+ }
+
+ return {
+ group,
+ rect,
+ handles,
+ guides,
+ };
+ };
+
+ /**
+ * Create the Konva rectangle used for crop box interaction (dragging the crop box).
+ */
+ private createKonvaCropInteractionRect = (): Konva.Rect => {
+ const rect = new Konva.Rect({
+ stroke: 'white',
+ strokeWidth: 1,
+ strokeScaleEnabled: false,
+ draggable: true,
+ });
+
+ // Prevent crop box dragging when panning
+ rect.on('dragstart', (e) => {
+ if (this.isSpacePressed || this.isPanning) {
+ e.target.stopDrag();
+ return false;
+ }
+ });
+
+ // Crop box dragging
+ rect.on('dragmove', () => {
+ if (!this.konva?.image.image || !this.cropBox) {
+ return;
+ }
+
+ const imgWidth = this.konva.image.image.width();
+ const imgHeight = this.konva.image.image.height();
+
+ // Constrain to image bounds
+ const x = Math.max(0, Math.min(rect.x(), imgWidth - rect.width()));
+ const y = Math.max(0, Math.min(rect.y(), imgHeight - rect.height()));
+ const { width, height } = this.cropBox;
+
+ rect.x(x);
+ rect.y(y);
+
+ this.updateCropBox({ x, y, width, height });
+ });
+
+ rect.on('mouseenter', () => {
+ const stage = this.konva?.stage;
+ if (!stage) {
+ return;
+ }
+ if (!this.isSpacePressed) {
+ stage.container().style.cursor = 'move';
+ }
+ });
+
+ rect.on('mouseleave', () => {
+ const stage = this.konva?.stage;
+ if (!stage) {
+ return;
+ }
+ if (!this.isSpacePressed) {
+ stage.container().style.cursor = 'default';
+ }
+ });
+
+ return rect;
+ };
+
+ /**
+ * Create a Konva line used as a crop box guide (one of the "rule of thirds" lines).
+ */
+ private createKonvaCropInteractionGuide = (name: GuideName): Konva.Line => {
+ const line = new Konva.Line({
+ name,
+ stroke: this.config.CROP_GUIDE_STROKE,
+ strokeWidth: this.config.CROP_GUIDE_STROKE_WIDTH,
+ strokeScaleEnabled: false,
+ listening: false,
+ });
+
+ return line;
+ };
+
+ /**
+ * Create a Konva rectangle used as a crop box resize handle.
+ */
+ private createKonvaCropInteractionHandle = (name: HandleName): Konva.Rect => {
+ const rect = new Konva.Rect({
+ name,
+ x: 0,
+ y: 0,
+ width: this.config.CROP_HANDLE_SIZE,
+ height: this.config.CROP_HANDLE_SIZE,
+ fill: this.config.CROP_HANDLE_FILL,
+ stroke: this.config.CROP_HANDLE_STROKE,
+ strokeWidth: this.config.CROP_HANDLE_STROKE_WIDTH,
+ strokeScaleEnabled: true,
+ draggable: true,
+ hitStrokeWidth: 16,
+ });
+
+ // Prevent handle dragging when panning
+ rect.on('dragstart', () => {
+ const stage = this.konva?.stage;
+ if (!stage) {
+ return;
+ }
+ if (stage.isDragging()) {
+ rect.stopDrag();
+ return false;
+ }
+ });
+
+ // Set cursor based on handle type
+ rect.on('mouseenter', () => {
+ const stage = this.konva?.stage;
+ if (!stage) {
+ return;
+ }
+ if (!stage.isDragging()) {
+ let cursor = 'pointer';
+ if (name === 'top-left' || name === 'bottom-right') {
+ cursor = 'nwse-resize';
+ } else if (name === 'top-right' || name === 'bottom-left') {
+ cursor = 'nesw-resize';
+ } else if (name === 'top' || name === 'bottom') {
+ cursor = 'ns-resize';
+ } else if (name === 'left' || name === 'right') {
+ cursor = 'ew-resize';
+ }
+ stage.container().style.cursor = cursor;
+ }
+ });
+
+ rect.on('mouseleave', () => {
+ const stage = this.konva?.stage;
+ if (!stage) {
+ return;
+ }
+ if (!stage.isDragging()) {
+ stage.container().style.cursor = 'default';
+ }
+ });
+
+ // Handle dragging
+ rect.on('dragmove', () => {
+ if (!this.konva) {
+ return;
+ }
+
+ const { newX, newY, newWidth, newHeight } = this.aspectRatio
+ ? this.getNextCropBoxByHandleWithAspectRatio(name, rect)
+ : this.getNextCropBoxByHandleFree(name, rect);
+
+ this.updateCropBox({
+ x: newX,
+ y: newY,
+ width: newWidth,
+ height: newHeight,
+ });
+ });
+
+ return rect;
+ };
+
+ /**
+ * Update (render) the Konva rectangle used for crop box interaction (dragging the crop box).
+ */
+ private updateKonvaCropInteractionRect = () => {
+ if (!this.konva || !this.cropBox) {
+ return;
+ }
+ this.konva.crop.interaction.rect.setAttrs({ ...this.cropBox });
+ };
+
+ /**
+ * Update (render) the Konva lines used as crop box guides (the "rule of thirds" lines).
+ */
+ private updateKonvaCropInteractionGuides = () => {
+ if (!this.konva || !this.cropBox) {
+ return;
+ }
+
+ const { x, y, width, height } = this.cropBox;
+
+ const verticalThird = width / 3;
+ this.konva.crop.interaction.guides.left.points([x + verticalThird, y, x + verticalThird, y + height]);
+ this.konva.crop.interaction.guides.right.points([x + verticalThird * 2, y, x + verticalThird * 2, y + height]);
+
+ const horizontalThird = height / 3;
+ this.konva.crop.interaction.guides.top.points([x, y + horizontalThird, x + width, y + horizontalThird]);
+ this.konva.crop.interaction.guides.bottom.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]);
+ };
+
+ /**
+ * Update (render) the Konva rectangles used as crop box resize handles. Only the positions are updated in this
+ * method.
+ */
+ private updateKonvaCropInteractionHandlePositions = () => {
+ if (!this.konva || !this.cropBox) {
+ return;
+ }
+
+ for (const [handleName, handleRect] of objectEntries(this.konva.crop.interaction.handles)) {
+ const { x, y, width, height } = this.cropBox;
+ const handleSize = handleRect.width();
+
+ let handleX = x;
+ let handleY = y;
+
+ if (handleName.includes('right')) {
+ handleX += width;
+ } else if (!handleName.includes('left')) {
+ handleX += width / 2;
+ }
+
+ if (handleName.includes('bottom')) {
+ handleY += height;
+ } else if (!handleName.includes('top')) {
+ handleY += height / 2;
+ }
+
+ handleRect.x(handleX - handleSize / 2);
+ handleRect.y(handleY - handleSize / 2);
+ }
+ };
+
+ /**
+ * Update (render) the Konva rectangles used as crop box resize handles. Only the sizes and stroke widths are updated
+ * in this method to maintain a constant screen size regardless of zoom level.
+ */
+ private updateKonvaCropInteractionHandleScales = () => {
+ if (!this.konva) {
+ return;
+ }
+
+ const scale = this.konva.stage.scaleX();
+ const handleSize = this.config.CROP_HANDLE_SIZE / scale;
+ const strokeWidth = this.config.CROP_HANDLE_STROKE_WIDTH / scale;
+
+ for (const handle of Object.values(this.konva.crop.interaction.handles)) {
+ const currentX = handle.x();
+ const currentY = handle.y();
+ const oldSize = handle.width();
+
+ // Calculate center position
+ const centerX = currentX + oldSize / 2;
+ const centerY = currentY + oldSize / 2;
+
+ // Update size and stroke
+ handle.width(handleSize);
+ handle.height(handleSize);
+ handle.strokeWidth(strokeWidth);
+
+ // Reposition to maintain center
+ handle.x(centerX - handleSize / 2);
+ handle.y(centerY - handleSize / 2);
+ }
+ };
+
+ /**
+ * Update the crop box state and re-render all related Konva objects.
+ */
+ private updateCropBox = (cropBox: CropBox) => {
+ this.cropBox = cropBox;
+ this.updateKonvaCropOverlay();
+ this.updateKonvaCropInteractionRect();
+ this.updateKonvaCropInteractionGuides();
+ this.updateKonvaCropInteractionHandlePositions();
+ for (const cb of this.callbacks.onCropBoxChange) {
+ cb(cropBox);
+ }
+ };
+
+ /**
+ * Update (render) the Konva background objects (the checkerboard pattern).
+ */
+ private updateKonvaBg = () => {
+ if (!this.konva) {
+ return;
+ }
+ const scale = this.konva.stage.scaleX();
+ const patternScale = 1 / scale;
+ const { x, y } = this.konva.stage.getPosition();
+ const { width, height } = this.konva.stage.size();
+
+ this.konva.bg.rect.setAttrs({
+ visible: true,
+ x: Math.floor(-x / scale),
+ y: Math.floor(-y / scale),
+ width: Math.ceil(width / scale),
+ height: Math.ceil(height / scale),
+ fillPatternScaleX: patternScale,
+ fillPatternScaleY: patternScale,
+ });
+ };
+
+ /**
+ * Update (render) the Konva crop overlay objects (the darkened area outside the crop box).
+ */
+ private updateKonvaCropOverlay = () => {
+ if (!this.konva?.image.image || !this.cropBox) {
+ return;
+ }
+
+ // Make the overlay cover the entire image
+ this.konva.crop.overlay.full.setAttrs({
+ ...this.konva.image.image.getPosition(),
+ ...this.konva.image.image.getSize(),
+ });
+
+ // Clear the crop area from the overlay
+ this.konva.crop.overlay.clear.setAttrs({ ...this.cropBox });
+ };
+
+ /**
+ * Update (render) the Konva image object when a new image is loaded.
+ *
+ * This shouldn't be called during normal renders.
+ */
+ private updateImage = (initial?: { cropBox: CropBox; aspectRatio: number | null }) => {
+ if (!this.originalImage || !this.konva) {
+ return;
+ }
+
+ // Clear existing image
+ if (this.konva.image.image) {
+ this.konva.image.image.destroy();
+ this.konva.image.image = undefined;
+ }
+
+ const imageNode = new Konva.Image({
+ image: this.originalImage,
+ x: 0,
+ y: 0,
+ width: this.originalImage.width,
+ height: this.originalImage.height,
+ });
+
+ this.konva.image.image = imageNode;
+ this.konva.image.layer.add(imageNode);
+
+ // Center image at 100% zoom
+ this.resetView();
+
+ if (initial) {
+ this.setCropAspectRatio(initial.aspectRatio);
+ this.updateCropBox(initial.cropBox);
+ } else {
+ // Create default crop box (centered, 80% of image)
+ const imgWidth = this.konva.image.image.width();
+ const imgHeight = this.konva.image.image.height();
+ const width = imgWidth * this.config.DEFAULT_CROP_BOX_SCALE;
+ const height = imgHeight * this.config.DEFAULT_CROP_BOX_SCALE;
+ const x = (imgWidth - width) / 2;
+ const y = (imgHeight - height) / 2;
+ this.updateCropBox({ x, y, width, height });
+ }
+ };
+
+ /**
+ * Calculate the next crop box dimensions when dragging a handle in freeform mode (no aspect ratio).
+ *
+ * The handle that was dragged determines which edges of the crop box are adjusted.
+ *
+ * TODO(psyche): Konva's Transformer class can handle this logic. Explore refactoring to use it.
+ */
+ private getNextCropBoxByHandleFree = (handleName: HandleName, handleRect: Konva.Rect) => {
+ if (!this.konva?.image.image || !this.cropBox) {
+ throw new Error('Crop box or image not found');
+ }
+
+ const imgWidth = this.konva.image.image.width();
+ const imgHeight = this.konva.image.image.height();
+
+ let newX = this.cropBox.x;
+ let newY = this.cropBox.y;
+ let newWidth = this.cropBox.width;
+ let newHeight = this.cropBox.height;
+
+ const handleX = handleRect.x() + handleRect.width() / 2;
+ const handleY = handleRect.y() + handleRect.height() / 2;
+
+ const minWidth = this.config.MIN_CROP_DIMENSION;
+ const minHeight = this.config.MIN_CROP_DIMENSION;
+
+ // Update dimensions based on handle type
+ if (handleName.includes('left')) {
+ const right = newX + newWidth;
+ newX = Math.max(0, Math.min(handleX, right - minWidth));
+ newWidth = right - newX;
+ }
+ if (handleName.includes('right')) {
+ newWidth = Math.max(minWidth, Math.min(handleX - newX, imgWidth - newX));
+ }
+ if (handleName.includes('top')) {
+ const bottom = newY + newHeight;
+ newY = Math.max(0, Math.min(handleY, bottom - minHeight));
+ newHeight = bottom - newY;
+ }
+ if (handleName.includes('bottom')) {
+ newHeight = Math.max(minHeight, Math.min(handleY - newY, imgHeight - newY));
+ }
+
+ return { newX, newY, newWidth, newHeight };
+ };
+
+ /**
+ * Calculate the next crop box dimensions when dragging a handle in fixed aspect ratio mode.
+ *
+ * The handle that was dragged determines which edges of the crop box are adjusted.
+ *
+ * TODO(psyche): Konva's Transformer class can handle this logic. Explore refactoring to use it.
+ */
+ private getNextCropBoxByHandleWithAspectRatio = (handleName: HandleName, handleRect: Konva.Rect) => {
+ if (!this.konva?.image.image || !this.aspectRatio || !this.cropBox) {
+ throw new Error('Crop box, image, or aspect ratio not found');
+ }
+ const imgWidth = this.konva.image.image.width();
+ const imgHeight = this.konva.image.image.height();
+ const ratio = this.aspectRatio;
+
+ const handleX = handleRect.x() + handleRect.width() / 2;
+ const handleY = handleRect.y() + handleRect.height() / 2;
+
+ const minWidth = this.config.MIN_CROP_DIMENSION;
+ const minHeight = this.config.MIN_CROP_DIMENSION;
+
+ // Early boundary check for aspect ratio mode
+ const atLeftEdge = this.cropBox.x <= 0;
+ const atRightEdge = this.cropBox.x + this.cropBox.width >= imgWidth;
+ const atTopEdge = this.cropBox.y <= 0;
+ const atBottomEdge = this.cropBox.y + this.cropBox.height >= imgHeight;
+
+ if (
+ (handleName === 'left' && atLeftEdge && handleX < this.cropBox.x) ||
+ (handleName === 'right' && atRightEdge && handleX > this.cropBox.x + this.cropBox.width) ||
+ (handleName === 'top' && atTopEdge && handleY < this.cropBox.y) ||
+ (handleName === 'bottom' && atBottomEdge && handleY > this.cropBox.y + this.cropBox.height)
+ ) {
+ return {
+ newX: this.cropBox.x,
+ newY: this.cropBox.y,
+ newWidth: this.cropBox.width,
+ newHeight: this.cropBox.height,
+ };
+ }
+
+ const {
+ newX: freeX,
+ newY: freeY,
+ newWidth: freeWidth,
+ newHeight: freeHeight,
+ } = this.getNextCropBoxByHandleFree(handleName, handleRect);
+
+ let newX = freeX;
+ let newY = freeY;
+ let newWidth = freeWidth;
+ let newHeight = freeHeight;
+
+ const oldX = this.cropBox.x;
+ const oldY = this.cropBox.y;
+ const oldWidth = this.cropBox.width;
+ const oldHeight = this.cropBox.height;
+
+ // Define anchor points (opposite of the handle being dragged)
+ let anchorX = oldX;
+ let anchorY = oldY;
+
+ if (handleName.includes('right')) {
+ anchorX = oldX; // Left edge is anchor
+ } else if (handleName.includes('left')) {
+ anchorX = oldX + oldWidth; // Right edge is anchor
+ } else {
+ anchorX = oldX + oldWidth / 2; // Center X is anchor for top/bottom
+ }
+
+ if (handleName.includes('bottom')) {
+ anchorY = oldY; // Top edge is anchor
+ } else if (handleName.includes('top')) {
+ anchorY = oldY + oldHeight; // Bottom edge is anchor
+ } else {
+ anchorY = oldY + oldHeight / 2; // Center Y is anchor for left/right
+ }
+
+ // Calculate new dimensions maintaining aspect ratio
+ if (handleName === 'left' || handleName === 'right' || handleName === 'top' || handleName === 'bottom') {
+ if (handleName === 'left' || handleName === 'right') {
+ newHeight = newWidth / ratio;
+ newY = anchorY - newHeight / 2;
+ } else {
+ // top or bottom
+ newWidth = newHeight * ratio;
+ newX = anchorX - newWidth / 2;
+ }
+ } else {
+ // Corner handles
+ const mouseDistanceFromAnchorX = Math.abs(handleX - anchorX);
+ const mouseDistanceFromAnchorY = Math.abs(handleY - anchorY);
+
+ let maxPossibleWidth = handleName.includes('left') ? anchorX : imgWidth - anchorX;
+ let maxPossibleHeight = handleName.includes('top') ? anchorY : imgHeight - anchorY;
+
+ const constrainedMouseDistanceX = Math.min(mouseDistanceFromAnchorX, maxPossibleWidth);
+ const constrainedMouseDistanceY = Math.min(mouseDistanceFromAnchorY, maxPossibleHeight);
+
+ if (constrainedMouseDistanceX / ratio > constrainedMouseDistanceY) {
+ newWidth = constrainedMouseDistanceX;
+ newHeight = newWidth / ratio;
+ if (newHeight > maxPossibleHeight) {
+ newHeight = maxPossibleHeight;
+ newWidth = newHeight * ratio;
+ }
+ } else {
+ newHeight = constrainedMouseDistanceY;
+ newWidth = newHeight * ratio;
+ if (newWidth > maxPossibleWidth) {
+ newWidth = maxPossibleWidth;
+ newHeight = newWidth / ratio;
+ }
+ }
+
+ newX = handleName.includes('left') ? anchorX - newWidth : anchorX;
+ newY = handleName.includes('top') ? anchorY - newHeight : anchorY;
+ }
+
+ // Boundary checks and adjustments
+ if (newX < 0) {
+ newX = 0;
+ newWidth = oldX + oldWidth;
+ newHeight = newWidth / ratio;
+ newY = handleName.includes('top') ? oldY + oldHeight - newHeight : oldY;
+ }
+ if (newY < 0) {
+ newY = 0;
+ newHeight = oldY + oldHeight;
+ newWidth = newHeight * ratio;
+ newX = handleName.includes('left') ? oldX + oldWidth - newWidth : oldX;
+ }
+ if (newX + newWidth > imgWidth) {
+ newWidth = imgWidth - newX;
+ newHeight = newWidth / ratio;
+ newY = handleName.includes('top') ? oldY + oldHeight - newHeight : oldY;
+ }
+ if (newY + newHeight > imgHeight) {
+ newHeight = imgHeight - newY;
+ newWidth = newHeight * ratio;
+ newX = handleName.includes('left') ? oldX + oldWidth - newWidth : oldX;
+ }
+
+ // Final check for minimum sizes
+ if (newWidth < minWidth || newHeight < minHeight) {
+ if (minWidth / ratio > minHeight) {
+ newWidth = minWidth;
+ newHeight = newWidth / ratio;
+ } else {
+ newHeight = minHeight;
+ newWidth = newHeight * ratio;
+ }
+ newX = handleName.includes('left') ? anchorX - newWidth : anchorX;
+ newY = handleName.includes('top') ? anchorY - newHeight : anchorY;
+ }
+
+ return { newX, newY, newWidth, newHeight };
+ };
+
+ //#region Event Handling
+
+ /**
+ * Set up event listeners for Konva stage (pointer) and window events (keyboard).
+ */
+ private setupListeners = () => {
+ if (!this.konva) {
+ return;
+ }
+
+ const stage = this.konva.stage;
+
+ stage.on('wheel', this.onWheel);
+ stage.on('contextmenu', this.onContextMenu);
+ stage.on('pointerdown', this.onPointerDown);
+ stage.on('pointerup', this.onPointerUp);
+ stage.on('pointermove', this.onPointerMove);
+
+ this.cleanupFunctions.add(() => {
+ stage.off('wheel', this.onWheel);
+ stage.off('contextmenu', this.onContextMenu);
+ stage.off('pointerdown', this.onPointerDown);
+ stage.off('pointerup', this.onPointerUp);
+ stage.off('pointermove', this.onPointerMove);
+ });
+
+ window.addEventListener('keydown', this.onKeyDown);
+ window.addEventListener('keyup', this.onKeyUp);
+
+ this.cleanupFunctions.add(() => {
+ window.removeEventListener('keydown', this.onKeyDown);
+ window.removeEventListener('keyup', this.onKeyUp);
+ });
+ };
+
+ /**
+ * Handle keydown events.
+ * - Space: Enable panning mode.
+ */
+ private onKeyDown = (e: KeyboardEvent) => {
+ if (!this.konva?.stage) {
+ return;
+ }
+ if (e.code === 'Space' && !this.isSpacePressed) {
+ e.preventDefault();
+ this.isSpacePressed = true;
+ this.konva.stage.container().style.cursor = 'grab';
+ }
+ };
+
+ /**
+ * Handle keyup events.
+ * - Space: Disable panning mode.
+ */
+ private onKeyUp = (e: KeyboardEvent) => {
+ if (!this.konva?.stage) {
+ return;
+ }
+ if (e.code === 'Space') {
+ e.preventDefault();
+ this.isSpacePressed = false;
+ this.isPanning = false;
+ // Revert cursor to default; mouseenter events will set it correctly if over an interactive element.
+ this.konva.stage.container().style.cursor = 'default';
+ }
+ };
+
+ /**
+ * Handle mouse wheel events for zooming in/out.
+ * - Zoom is centered on the mouse pointer position and constrained to min/max levels.
+ * - The crop box handles are rescaled to maintain a constant screen size.
+ * - The background pattern is rescalted to maintain a constant screen size.
+ */
+ private onWheel = (e: KonvaEventObject) => {
+ if (!this.konva?.stage) {
+ return;
+ }
+ e.evt.preventDefault();
+
+ const oldScale = this.konva.stage.scaleX();
+ const pointer = this.konva.stage.getPointerPosition();
+
+ if (!pointer) {
+ return;
+ }
+
+ const mousePointTo = {
+ x: (pointer.x - this.konva.stage.x()) / oldScale,
+ y: (pointer.y - this.konva.stage.y()) / oldScale,
+ };
+
+ const direction = e.evt.deltaY > 0 ? -1 : 1;
+ const scaleBy = this.config.ZOOM_WHEEL_FACTOR;
+ let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
+
+ // Apply zoom limits
+ newScale = Math.max(this.config.ZOOM_MIN_PCT, Math.min(this.config.ZOOM_MAX_PCT, newScale));
+
+ this.konva.stage.scale({ x: newScale, y: newScale });
+
+ const newPos = {
+ x: pointer.x - mousePointTo.x * newScale,
+ y: pointer.y - mousePointTo.y * newScale,
+ };
+ this.konva.stage.position(newPos);
+
+ // Update handle scaling to maintain constant screen size
+ this.updateKonvaCropInteractionHandleScales();
+ this.updateKonvaBg();
+ for (const cb of this.callbacks.onZoomChange) {
+ cb(newScale);
+ }
+ };
+
+ /**
+ * Handle pointer down events to initiate panning mode if spacebar is pressed or middle mouse button is used.
+ * - Stops any active drags on crop elements to prevent conflicts.
+ */
+ private onPointerDown = (e: KonvaEventObject) => {
+ if (!this.konva?.stage) {
+ return;
+ }
+ if (this.isSpacePressed || e.evt.button === 1) {
+ e.evt.preventDefault();
+ e.evt.stopPropagation();
+ this.isPanning = true;
+ this.lastPointerPosition = this.konva.stage.getPointerPosition();
+ this.konva.stage.container().style.cursor = 'grabbing';
+
+ // Stop any active drags on crop elements
+ if (this.konva.crop) {
+ if (this.konva.crop.interaction.rect.isDragging()) {
+ this.konva.crop.interaction.rect.stopDrag();
+ }
+ for (const handle of Object.values(this.konva.crop.interaction.handles)) {
+ if (handle.isDragging()) {
+ handle.stopDrag();
+ }
+ }
+ }
+ }
+ };
+
+ /**
+ * Handle pointer move events to pan the image when in panning mode.
+ */
+ private onPointerMove = (_: KonvaEventObject) => {
+ if (!this.konva?.stage) {
+ return;
+ }
+ if (!this.isPanning || !this.lastPointerPosition) {
+ return;
+ }
+
+ const pointer = this.konva.stage.getPointerPosition();
+ if (!pointer) {
+ return;
+ }
+
+ const dx = pointer.x - this.lastPointerPosition.x;
+ const dy = pointer.y - this.lastPointerPosition.y;
+
+ this.konva.stage.x(this.konva.stage.x() + dx);
+ this.konva.stage.y(this.konva.stage.y() + dy);
+
+ this.updateKonvaBg();
+
+ this.lastPointerPosition = pointer;
+ };
+
+ /**
+ * Handle pointer up events to exit panning mode.
+ */
+ private onPointerUp = (_: KonvaEventObject) => {
+ if (!this.konva?.stage) {
+ return;
+ }
+ if (this.isPanning) {
+ this.isPanning = false;
+ this.konva.stage.container().style.cursor = this.isSpacePressed ? 'grab' : 'default';
+ }
+ };
+
+ /**
+ * Handle context menu events to prevent the default browser context menu from appearing on right-click.
+ */
+ private onContextMenu = (e: KonvaEventObject) => {
+ e.evt.preventDefault();
+ };
+ //#region Event Handling
+
+ //#region Public API
+
+ /**
+ * Load an image from a URL or data URL.
+ * @param src The image source URL or data URL.
+ * @returns A promise that resolves when the image is loaded or rejects on error.
+ */
+ loadImage = (src: string, initial?: { cropBox: CropBox; aspectRatio: number | null }): Promise => {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+
+ img.crossOrigin = $crossOrigin.get();
+
+ img.onload = () => {
+ this.originalImage = img;
+ this.updateImage(initial);
+ resolve();
+ };
+
+ img.onerror = () => {
+ reject(new Error('Failed to load image'));
+ };
+
+ img.src = src;
+ });
+ };
+
+ /**
+ * Reset the crop box to encompass the entire image.
+ */
+ resetCrop = () => {
+ if (!this.konva?.image.image) {
+ return;
+ }
+ this.updateCropBox({
+ x: 0,
+ y: 0,
+ ...this.konva.image.image.size(),
+ });
+ };
+
+ /**
+ * Export the current image with the current crop applied, in the specified format.
+ *
+ * If there is no crop box, the full image is exported.
+ *
+ * @param format The output format: 'canvas', 'blob', or 'dataURL'. Defaults to 'blob'.
+ * @returns A promise that resolves with the exported image in the requested format.
+ */
+ exportImage = (
+ format: T = 'blob' as T,
+ options?: { withCropOverlay?: boolean }
+ ): Promise> => {
+ const { withCropOverlay } = { withCropOverlay: false, ...options };
+ return new Promise((resolve, reject) => {
+ if (!this.originalImage) {
+ throw new Error('No image loaded');
+ }
+
+ // Create temporary canvas
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ throw new Error('Failed to get canvas context');
+ }
+
+ try {
+ if (this.cropBox) {
+ if (!withCropOverlay) {
+ // Draw the cropped image
+ canvas.width = this.cropBox.width;
+ canvas.height = this.cropBox.height;
+
+ ctx.drawImage(
+ this.originalImage,
+ this.cropBox.x,
+ this.cropBox.y,
+ this.cropBox.width,
+ this.cropBox.height,
+ 0,
+ 0,
+ this.cropBox.width,
+ this.cropBox.height
+ );
+ } else {
+ // Draw the full image with dark overlay and clear crop area
+ canvas.width = this.originalImage.width;
+ canvas.height = this.originalImage.height;
+
+ ctx.drawImage(this.originalImage, 0, 0);
+
+ // We need a new canvas for the overlay to avoid messing up the original image when clearing the crop area
+ const overlayCanvas = document.createElement('canvas');
+ overlayCanvas.width = this.originalImage.width;
+ overlayCanvas.height = this.originalImage.height;
+
+ const overlayCtx = overlayCanvas.getContext('2d');
+ if (!overlayCtx) {
+ throw new Error('Failed to get canvas context');
+ }
+
+ overlayCtx.fillStyle = this.config.CROP_OVERLAY_FILL_COLOR;
+ overlayCtx.fillRect(0, 0, overlayCanvas.width, overlayCanvas.height);
+ overlayCtx.clearRect(this.cropBox.x, this.cropBox.y, this.cropBox.width, this.cropBox.height);
+
+ ctx.globalCompositeOperation = 'multiply';
+ ctx.drawImage(overlayCanvas, 0, 0);
+
+ overlayCanvas.remove();
+ }
+ } else {
+ canvas.width = this.originalImage.width;
+ canvas.height = this.originalImage.height;
+ ctx.drawImage(this.originalImage, 0, 0);
+ }
+
+ if (format === 'canvas') {
+ resolve(canvas as OutputFormatToOutputMap);
+ } else if (format === 'dataURL') {
+ try {
+ resolve(canvas.toDataURL('image/png') as OutputFormatToOutputMap);
+ } catch (error) {
+ reject(error);
+ }
+ } else {
+ try {
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob as OutputFormatToOutputMap);
+ } else {
+ reject(new Error('Failed to create blob'));
+ }
+ }, 'image/png');
+ } catch (error) {
+ reject(error);
+ }
+ }
+ } catch (error) {
+ reject(error);
+ }
+ });
+ };
+
+ /**
+ * Set the zoom level, optionally centered on a specific point.
+ * @param scale The target zoom scale (1 = 100%).
+ * @param point Optional point to center the zoom on, in stage coordinates. Defaults to center of viewport.
+ */
+ setZoom = (scale: number, point?: { x: number; y: number }) => {
+ if (!this.konva) {
+ return;
+ }
+
+ scale = Math.max(this.config.ZOOM_MIN_PCT, Math.min(this.config.ZOOM_MAX_PCT, scale));
+
+ // If no point provided, use center of viewport
+ if (!point && this.konva.image) {
+ const containerWidth = this.konva.stage.width();
+ const containerHeight = this.konva.stage.height();
+ point = {
+ x: containerWidth / 2,
+ y: containerHeight / 2,
+ };
+ }
+
+ if (point) {
+ const oldScale = this.konva.stage.scaleX();
+ const mousePointTo = {
+ x: (point.x - this.konva.stage.x()) / oldScale,
+ y: (point.y - this.konva.stage.y()) / oldScale,
+ };
+
+ this.konva.stage.scale({ x: scale, y: scale });
+
+ const newPos = {
+ x: point.x - mousePointTo.x * scale,
+ y: point.y - mousePointTo.y * scale,
+ };
+ this.konva.stage.position(newPos);
+ } else {
+ this.konva.stage.scale({ x: scale, y: scale });
+ }
+
+ // Update handle scaling
+ this.updateKonvaCropInteractionHandleScales();
+
+ this.updateKonvaBg();
+
+ for (const cb of this.callbacks.onZoomChange) {
+ cb(scale);
+ }
+ };
+
+ /**
+ * Get the current zoom level (1 = 100%).
+ */
+ getZoom = (): number => {
+ return this.konva?.stage.scaleX() || 1;
+ };
+
+ /**
+ * Zoom in/out by a fixed factor, optionally centered on a specific point.
+ * @param point Optional point to center the zoom on, in stage coordinates. Defaults to center of viewport.
+ */
+ zoomIn = (point?: { x: number; y: number }) => {
+ const currentZoom = this.getZoom();
+ this.setZoom(currentZoom * this.config.ZOOM_BUTTON_FACTOR, point);
+ };
+
+ /**
+ * Zoom out by a fixed factor, optionally centered on a specific point.
+ * @param point Optional point to center the zoom on, in stage coordinates. Defaults to center of viewport.
+ */
+ zoomOut = (point?: { x: number; y: number }) => {
+ const currentZoom = this.getZoom();
+ this.setZoom(currentZoom / this.config.ZOOM_BUTTON_FACTOR, point);
+ };
+
+ /**
+ * Reset the view to 100% zoom and center the image in the container.
+ */
+ resetView = () => {
+ if (!this.konva?.image.image) {
+ return;
+ }
+
+ this.konva.stage.scale({ x: 1, y: 1 });
+
+ // Center the image
+ const containerWidth = this.konva.stage.width();
+ const containerHeight = this.konva.stage.height();
+ const imageWidth = this.konva.image.image.width();
+ const imageHeight = this.konva.image.image.height();
+
+ this.konva.stage.position({
+ x: (containerWidth - imageWidth) / 2,
+ y: (containerHeight - imageHeight) / 2,
+ });
+
+ // Update handle scaling
+ this.updateKonvaCropInteractionHandleScales();
+
+ this.updateKonvaBg();
+
+ for (const cb of this.callbacks.onZoomChange) {
+ cb(1);
+ }
+ };
+
+ /**
+ * Scale the image to fit within the container while maintaining aspect ratio.
+ * Adds padding to ensure the image isn't flush against container edges.
+ */
+ fitToContainer = () => {
+ if (!this.konva?.image?.image) {
+ return;
+ }
+
+ const containerWidth = this.konva.stage.width();
+ const containerHeight = this.konva.stage.height();
+ const imageWidth = this.konva.image.image.width();
+ const imageHeight = this.konva.image.image.height();
+
+ const scale =
+ Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * this.config.FIT_TO_CONTAINER_PADDING_PCT;
+
+ this.konva.stage.scale({ x: scale, y: scale });
+
+ // Center the image
+ const scaledWidth = imageWidth * scale;
+ const scaledHeight = imageHeight * scale;
+
+ this.konva.stage.position({
+ x: (containerWidth - scaledWidth) / 2,
+ y: (containerHeight - scaledHeight) / 2,
+ });
+
+ // Update handle scaling
+ this.updateKonvaCropInteractionHandleScales();
+
+ this.updateKonvaBg();
+
+ for (const cb of this.callbacks.onZoomChange) {
+ cb(scale);
+ }
+ };
+
+ /**
+ * Set or update event callbacks.
+ * @param callbacks The callbacks to set or update.
+ * @param replace If true, replaces all existing callbacks. If false, merges with existing callbacks. Default is false.
+ */
+ setCallbacks = (callbacks: EditorCallbacks, replace = false) => {
+ if (replace) {
+ this.callbacks = callbacks;
+ } else {
+ this.callbacks = { ...this.callbacks, ...callbacks };
+ }
+ };
+
+ /**
+ * Set or update the crop aspect ratio constraint.
+ * @param ratio The desired aspect ratio (width / height) or null to remove the constraint.
+ *
+ * If setting a new aspect ratio, the crop box is adjusted to maintain its area while fitting within image bounds.
+ * Minimum size constraints are applied as needed.
+ */
+ setCropAspectRatio = (ratio: number | null) => {
+ this.aspectRatio = ratio;
+
+ if (!this.konva?.image.image || !this.cropBox) {
+ return;
+ }
+
+ const currentWidth = this.cropBox.width;
+ const currentHeight = this.cropBox.height;
+ const currentArea = currentWidth * currentHeight;
+
+ if (ratio === null) {
+ // Just removed the aspect ratio constraint, no need to adjust
+ return;
+ }
+
+ // Calculate new dimensions maintaining the same area
+ // area = width * height
+ // ratio = width / height
+ // So: area = width * (width / ratio)
+ // Therefore: width = sqrt(area * ratio)
+ let newWidth = Math.sqrt(currentArea * ratio);
+ let newHeight = newWidth / ratio;
+
+ // Get image bounds
+ const imgWidth = this.konva.image.image.width();
+ const imgHeight = this.konva.image.image.height();
+
+ // Check if the new dimensions would exceed image bounds
+ if (newWidth > imgWidth || newHeight > imgHeight) {
+ // Scale down to fit within image bounds while maintaining ratio
+ const scaleX = imgWidth / newWidth;
+ const scaleY = imgHeight / newHeight;
+ const scale = Math.min(scaleX, scaleY);
+ newWidth *= scale;
+ newHeight *= scale;
+ }
+
+ // Apply minimum size constraints
+ const minWidth = this.config.MIN_CROP_DIMENSION;
+ const minHeight = this.config.MIN_CROP_DIMENSION;
+
+ if (newWidth < minWidth) {
+ newWidth = minWidth;
+ newHeight = newWidth / ratio;
+ }
+ if (newHeight < minHeight) {
+ newHeight = minHeight;
+ newWidth = newHeight * ratio;
+ }
+
+ // Center the new crop box at the same position as the old one
+ const currentCenterX = this.cropBox.x + currentWidth / 2;
+ const currentCenterY = this.cropBox.y + currentHeight / 2;
+
+ let newX = currentCenterX - newWidth / 2;
+ let newY = currentCenterY - newHeight / 2;
+
+ // Ensure the crop box stays within image bounds
+ newX = Math.max(0, Math.min(newX, imgWidth - newWidth));
+ newY = Math.max(0, Math.min(newY, imgHeight - newHeight));
+
+ this.updateCropBox({
+ x: newX,
+ y: newY,
+ width: newWidth,
+ height: newHeight,
+ });
+
+ for (const cb of this.callbacks.onAspectRatioChange) {
+ cb(ratio);
+ }
+ };
+
+ setCropBox = (box: CropBox) => {
+ this.updateCropBox(box);
+ };
+
+ getCropBox = (): CropBox | null => {
+ return this.cropBox;
+ };
+
+ /**
+ * Get the current crop aspect ratio constraint.
+ * @returns The current aspect ratio (width / height) or null if no constraint is set.
+ */
+ getCropAspectRatio = (): number | null => {
+ return this.aspectRatio;
+ };
+
+ /**
+ * Register a callback for when the crop box changes (moved or resized).
+ */
+ onCropBoxChange = (cb: (crop: Readonly) => void): (() => void) => {
+ this.callbacks.onCropBoxChange.add(cb);
+ return () => {
+ this.callbacks.onCropBoxChange.delete(cb);
+ };
+ };
+
+ /**
+ * Register a callback for when the zoom level changes.
+ */
+ onZoomChange = (cb: (zoom: number) => void): (() => void) => {
+ this.callbacks.onZoomChange.add(cb);
+ return () => {
+ this.callbacks.onZoomChange.delete(cb);
+ };
+ };
+
+ /**
+ * Register a callback for when the aspect ratio changes.
+ */
+ onAspectRatioChange = (cb: (ratio: number | null) => void): (() => void) => {
+ this.callbacks.onAspectRatioChange.add(cb);
+ return () => {
+ this.callbacks.onAspectRatioChange.delete(cb);
+ };
+ };
+
+ /**
+ * Resize the editor container and adjust the Konva stage accordingly.
+ *
+ * Use this method when the container size changes (e.g., window resize) to ensure the canvas fits properly.
+ *
+ * @param width The new container width in pixels.
+ * @param height The new container height in pixels.
+ */
+ resize = (width: number, height: number) => {
+ if (!this.konva) {
+ return;
+ }
+
+ this.konva.stage.width(width);
+ this.konva.stage.height(height);
+
+ this.updateKonvaBg();
+ };
+
+ /**
+ * Destroy the editor instance, cleaning up all resources and event listeners.
+ *
+ * After calling this method, the instance should not be used again.
+ */
+ destroy = () => {
+ for (const cleanup of this.cleanupFunctions) {
+ cleanup();
+ }
+
+ this.konva?.stage.destroy();
+
+ // Clear all references
+ this.konva = null;
+ this.originalImage = null;
+ this.cropBox = null;
+ for (const set of Object.values(this.callbacks)) {
+ set.clear();
+ }
+ };
+ //#endregion Public API
+}
diff --git a/invokeai/frontend/web/src/features/cropper/store/index.ts b/invokeai/frontend/web/src/features/cropper/store/index.ts
new file mode 100644
index 00000000000..0f063c7cca1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/cropper/store/index.ts
@@ -0,0 +1,26 @@
+import type { Editor } from 'features/cropper/lib/editor';
+import { atom } from 'nanostores';
+
+export type CropImageModalState = {
+ editor: Editor;
+ onApplyCrop: () => Promise | void;
+ onReady: () => Promise | void;
+};
+
+const $state = atom(null);
+
+const open = (state: CropImageModalState) => {
+ $state.set(state);
+};
+
+const close = () => {
+ const state = $state.get();
+ state?.editor.destroy();
+ $state.set(null);
+};
+
+export const cropImageModalApi = {
+ $state,
+ open,
+ close,
+};
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts
index 26738b42150..38aa8b039f3 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts
+++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts
@@ -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)
diff --git a/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx b/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx
index 6b634b898b4..192711c5441 100644
--- a/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx
+++ b/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx
@@ -3,7 +3,7 @@ import { IconButton } from '@invoke-ai/ui-library';
import type { MouseEvent } from 'react';
import { memo } from 'react';
-const sx: SystemStyleObject = {
+export const imageButtonSx: SystemStyleObject = {
minW: 0,
svg: {
transitionProperty: 'common',
@@ -31,7 +31,7 @@ export const DndImageIcon = memo((props: Props) => {
aria-label={tooltip}
icon={icon}
variant="link"
- sx={sx}
+ sx={imageButtonSx}
data-testid={tooltip}
{...rest}
/>
diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts
index e092e4fb80d..0aef104869f 100644
--- a/invokeai/frontend/web/src/features/dnd/dnd.ts
+++ b/invokeai/frontend/web/src/features/dnd/dnd.ts
@@ -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 { 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 } }));
},
};
@@ -641,7 +641,7 @@ export const videoFrameFromImageDndTarget: DndTarget {
const { imageDTO } = sourceData.payload;
- dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO)));
+ dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO)));
},
};
//#endregion
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx
index 430b51f2ace..b59f0addd7d 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx
@@ -1,4 +1,5 @@
import { MenuItem } from '@invoke-ai/ui-library';
+import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext';
import { startingFrameImageChanged } from 'features/parameters/store/videoSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
@@ -13,7 +14,7 @@ export const ContextMenuItemSendToVideo = memo(() => {
const dispatch = useDispatch();
const onClick = useCallback(() => {
- dispatch(startingFrameImageChanged(imageDTO));
+ dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO)));
navigationApi.switchToTab('video');
}, [imageDTO, dispatch]);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx
index ea789356c39..41505ae81d5 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx
@@ -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',
diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts
index c27f415da6d..14d27e900c1 100644
--- a/invokeai/frontend/web/src/features/imageActions/actions.ts
+++ b/invokeai/frontend/web/src/features/imageActions/actions.ts
@@ -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: {
diff --git a/invokeai/frontend/web/src/features/metadata/parsing.tsx b/invokeai/frontend/web/src/features/metadata/parsing.tsx
index d9b8b082db2..10cd0e32f7f 100644
--- a/invokeai/frontend/web/src/features/metadata/parsing.tsx
+++ b/invokeai/frontend/web/src/features/metadata/parsing.tsx
@@ -975,7 +975,7 @@ const RefImages: CollectionMetadataHandler = {
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);
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts
index 5ac97a80a14..10ae0b66c99 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts
@@ -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'],
});
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts
index 6c8e0af122d..f9cdf471f24 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts
@@ -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,
},
});
}
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts
index 7ab6c7e7fac..e96f698e631 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts
@@ -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++;
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts
index f64373c0d57..0e0e31667af 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts
@@ -50,7 +50,7 @@ export const buildChatGPT4oGraph = async (arg: GraphBuilderArg): Promise
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,
});
}
}
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts
index 77606360e48..29af7064005 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts
@@ -38,7 +38,7 @@ export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn
const startingFrameImage = selectStartingFrameImage(state);
assert(startingFrameImage, 'Video starting frame is required for runway video generation');
- const firstFrameImageField = zImageField.parse(startingFrameImage);
+ const firstFrameImageField = zImageField.parse(startingFrameImage.crop?.image ?? startingFrameImage.original);
const { seed, shouldRandomizeSeed } = params;
const { videoDuration, videoAspectRatio, videoResolution } = videoParams;
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts
index c611db14ae7..b33c9cdde5c 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts
@@ -61,7 +61,7 @@ export const buildVeo3VideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn =>
const startingFrameImage = selectStartingFrameImage(state);
if (startingFrameImage) {
- const startingFrameImageField = zImageField.parse(startingFrameImage);
+ const startingFrameImageField = zImageField.parse(startingFrameImage.crop?.image ?? startingFrameImage.original);
// @ts-expect-error: This node is not available in the OSS application
veo3VideoNode.starting_image = startingFrameImageField;
}
diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts
index 1ad49d792bf..2d15979a990 100644
--- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts
+++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts
@@ -4,7 +4,7 @@ import type { RootState } from 'app/store/store';
import type { SliceConfig } from 'app/store/types';
import { isPlainObject } from 'es-toolkit';
import type {
- ImageWithDims,
+ CroppableImageWithDims,
VideoAspectRatio,
VideoDuration,
VideoResolution,
@@ -16,7 +16,7 @@ import {
isVeo3AspectRatioID,
isVeo3DurationID,
isVeo3Resolution,
- zImageWithDims,
+ zCroppableImageWithDims,
zVideoAspectRatio,
zVideoDuration,
zVideoResolution,
@@ -30,8 +30,8 @@ import { assert } from 'tsafe';
import z from 'zod';
const zVideoState = z.object({
- _version: z.literal(1),
- startingFrameImage: zImageWithDims.nullable(),
+ _version: z.literal(2),
+ startingFrameImage: zCroppableImageWithDims.nullable(),
videoModel: zModelIdentifierField.nullable(),
videoResolution: zVideoResolution,
videoDuration: zVideoDuration,
@@ -42,7 +42,7 @@ export type VideoState = z.infer;
const getInitialState = (): VideoState => {
return {
- _version: 1,
+ _version: 2,
startingFrameImage: null,
videoModel: null,
videoResolution: '1080p',
@@ -55,7 +55,7 @@ const slice = createSlice({
name: 'video',
initialState: getInitialState(),
reducers: {
- startingFrameImageChanged: (state, action: PayloadAction) => {
+ startingFrameImageChanged: (state, action: PayloadAction) => {
state.startingFrameImage = action.payload;
},
@@ -119,6 +119,13 @@ export const videoSliceConfig: SliceConfig = {
if (!('_version' in state)) {
state._version = 1;
}
+ if (state._version === 1) {
+ state._version = 2;
+ if (state.startingFrameImage) {
+ // startingFrameImage changed from ImageWithDims to CroppableImageWithDims
+ state.startingFrameImage = zCroppableImageWithDims.parse({ original: state.startingFrameImage });
+ }
+ }
return zVideoState.parse(state);
},
},
diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts
index 542a661b7ac..e8a22e104ea 100644
--- a/invokeai/frontend/web/src/features/queue/store/readiness.ts
+++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts
@@ -309,7 +309,7 @@ const getReasonsWhyCannotEnqueueVideoTab = (arg: {
reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') });
}
- if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.image_name) {
+ if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.original.image.image_name) {
reasons.push({ content: i18n.t('parameters.invoke.noStartingFrameImage') });
}
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx
index a218afbf51f..ae8e03dcc6e 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx
@@ -1,20 +1,25 @@
-import { Flex, FormLabel, Text } from '@invoke-ai/ui-library';
+import { Flex, FormLabel, Icon, IconButton, Text, Tooltip } from '@invoke-ai/ui-library';
+import { objectEquals } from '@observ33r/object-equals';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { UploadImageIconButton } from 'common/hooks/useImageUploadButton';
-import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
+import { ASPECT_RATIO_MAP } 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 { videoFrameFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { DndImage } from 'features/dnd/DndImage';
-import { DndImageIcon } from 'features/dnd/DndImageIcon';
+import { DndImageIcon, imageButtonSx } from 'features/dnd/DndImageIcon';
import {
selectStartingFrameImage,
+ selectVideoAspectRatio,
selectVideoModelRequiresStartingFrame,
startingFrameImageChanged,
} from 'features/parameters/store/videoSlice';
import { t } from 'i18next';
-import { useCallback } from 'react';
-import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
-import { useImageDTO } from 'services/api/endpoints/images';
+import { useCallback, useMemo } from 'react';
+import { PiArrowCounterClockwiseBold, PiCropBold, PiWarningBold } from 'react-icons/pi';
+import { useImageDTO, useUploadImageMutation } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
const dndTargetData = videoFrameFromImageDndTarget.getData({ frame: 'start' });
@@ -23,7 +28,10 @@ export const StartingFrameImage = () => {
const dispatch = useAppDispatch();
const requiresStartingFrame = useAppSelector(selectVideoModelRequiresStartingFrame);
const startingFrameImage = useAppSelector(selectStartingFrameImage);
- const imageDTO = useImageDTO(startingFrameImage?.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();
const onReset = useCallback(() => {
dispatch(startingFrameImageChanged(null));
@@ -31,27 +39,106 @@ export const StartingFrameImage = () => {
const onUpload = useCallback(
(imageDTO: ImageDTO) => {
- dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO)));
+ dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO)));
},
[dispatch]
);
+ 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, startingFrameImage?.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
+ dispatch(startingFrameImageChanged(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();
+
+ dispatch(
+ startingFrameImageChanged(
+ imageDTOToCroppableImage(originalImageDTO, {
+ image: imageDTOToImageWithDims(newCroppedImageDTO),
+ box,
+ ratio: editor.getCropAspectRatio(),
+ })
+ )
+ );
+ };
+
+ const onReady = async () => {
+ const initial = startingFrameImage?.crop
+ ? { cropBox: startingFrameImage.crop.box, aspectRatio: startingFrameImage.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 });
+ }, [dispatch, originalImageDTO, startingFrameImage?.crop, uploadImage]);
+
+ const fitsCurrentAspectRatio = useMemo(() => {
+ const imageDTO = croppedImageDTO ?? originalImageDTO;
+ if (!imageDTO) {
+ return true;
+ }
+
+ const imageRatio = imageDTO.width / imageDTO.height;
+ const targetRatio = ASPECT_RATIO_MAP[videoAspectRatio].ratio;
+
+ // Call it a fit if the image is within 10% of the target aspect ratio
+ return Math.abs((imageRatio - targetRatio) / targetRatio) < 0.1;
+ }, [croppedImageDTO, originalImageDTO, videoAspectRatio]);
+
return (
- {t('parameters.startingFrameImage')}
+
+ {t('parameters.startingFrameImage')}
+ {!fitsCurrentAspectRatio && (
+
+
+
+
+
+ )}
+
- {!imageDTO && (
+ {!originalImageDTO && (
)}
- {imageDTO && (
+ {originalImageDTO && (
<>
-
+
{
tooltip={t('common.reset')}
/>
+
+
+ }
+ tooltip={t('common.crop')}
+ />
+
+
{
borderTopEndRadius="base"
borderBottomStartRadius="base"
pointerEvents="none"
- >{`${imageDTO.width}x${imageDTO.height}`}
+ >{`${croppedImageDTO?.width ?? originalImageDTO.width}x${croppedImageDTO?.height ?? originalImageDTO.height}`}
>
)}
diff --git a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx
index 8582f3911fb..c3746c875f6 100644
--- a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx
+++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx
@@ -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?.();
},
diff --git a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx
index a65d0a668dd..2f6d12ba3c8 100644
--- a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx
+++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx
@@ -1,7 +1,7 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
-import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
+import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
import { videoFrameFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { startingFrameImageChanged } from 'features/parameters/store/videoSlice';
@@ -21,7 +21,7 @@ export const LaunchpadStartingFrameButton = memo((props: { extraAction?: () => v
() =>
({
onUpload: (imageDTO: ImageDTO) => {
- dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO)));
+ dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO)));
props.extraAction?.();
},
allowMultiple: false,
diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts
index 8d1f1783d3e..b5b2827ee73 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/images.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts
@@ -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;
+};