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; +};