diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index e2e0e4ae95..acf3afabf6 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -532,7 +532,7 @@ "hidePreview": "Hide Preview", "showPreview": "Show Preview", "controlNetControlMode": "Control Mode", - "clipSkip": "Clip Skip", + "clipSkip": "CLIP Skip", "aspectRatio": "Ratio" }, "settings": { @@ -597,7 +597,10 @@ "metadataLoadFailed": "Failed to load metadata", "initialImageSet": "Initial Image Set", "initialImageNotSet": "Initial Image Not Set", - "initialImageNotSetDesc": "Could not load initial image" + "initialImageNotSetDesc": "Could not load initial image", + "nodesSaved": "Nodes Saved", + "nodesLoaded": "Nodes Loaded", + "nodesLoadedFailed": "Failed To Load Nodes" }, "tooltip": { "feature": { @@ -680,6 +683,8 @@ "swapSizes": "Swap Sizes" }, "nodes": { - "reloadSchema": "Reload Schema" + "reloadSchema": "Reload Schema", + "saveNodes": "Save Nodes", + "loadNodes": "Load Nodes" } } diff --git a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasBoundingBox.tsx b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasBoundingBox.tsx index 0241f3eb55..3421749658 100644 --- a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasBoundingBox.tsx +++ b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasBoundingBox.tsx @@ -12,6 +12,7 @@ import { setIsMovingBoundingBox, setIsTransformingBoundingBox, } from 'features/canvas/store/canvasSlice'; +import { uiSelector } from 'features/ui/store/uiSelectors'; import Konva from 'konva'; import { GroupConfig } from 'konva/lib/Group'; import { KonvaEventObject } from 'konva/lib/Node'; @@ -22,8 +23,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Group, Rect, Transformer } from 'react-konva'; const boundingBoxPreviewSelector = createSelector( - canvasSelector, - (canvas) => { + [canvasSelector, uiSelector], + (canvas, ui) => { const { boundingBoxCoordinates, boundingBoxDimensions, @@ -35,6 +36,8 @@ const boundingBoxPreviewSelector = createSelector( shouldSnapToGrid, } = canvas; + const { aspectRatio } = ui; + return { boundingBoxCoordinates, boundingBoxDimensions, @@ -45,6 +48,7 @@ const boundingBoxPreviewSelector = createSelector( shouldSnapToGrid, tool, hitStrokeWidth: 20 / stageScale, + aspectRatio, }; }, { @@ -70,6 +74,7 @@ const IAICanvasBoundingBox = (props: IAICanvasBoundingBoxPreviewProps) => { shouldSnapToGrid, tool, hitStrokeWidth, + aspectRatio, } = useAppSelector(boundingBoxPreviewSelector); const transformerRef = useRef(null); @@ -137,12 +142,22 @@ const IAICanvasBoundingBox = (props: IAICanvasBoundingBoxPreviewProps) => { const x = Math.round(rect.x()); const y = Math.round(rect.y()); - dispatch( - setBoundingBoxDimensions({ - width, - height, - }) - ); + if (aspectRatio) { + const newHeight = roundToMultiple(width / aspectRatio, 64); + dispatch( + setBoundingBoxDimensions({ + width: width, + height: newHeight, + }) + ); + } else { + dispatch( + setBoundingBoxDimensions({ + width, + height, + }) + ); + } dispatch( setBoundingBoxCoordinates({ @@ -154,7 +169,7 @@ const IAICanvasBoundingBox = (props: IAICanvasBoundingBoxPreviewProps) => { // Reset the scale now that the coords/dimensions have been un-scaled rect.scaleX(1); rect.scaleY(1); - }, [dispatch, shouldSnapToGrid]); + }, [dispatch, shouldSnapToGrid, aspectRatio]); const anchorDragBoundFunc = useCallback( ( diff --git a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts index 371719e087..550cfff4bc 100644 --- a/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/canvas/store/canvasSlice.ts @@ -7,7 +7,14 @@ import { import { IRect, Vector2d } from 'konva/lib/types'; import { clamp, cloneDeep } from 'lodash-es'; // +import { + setActiveTab, + setAspectRatio, + setShouldUseCanvasBetaLayout, +} from 'features/ui/store/uiSlice'; import { RgbaColor } from 'react-colorful'; +import { sessionCanceled } from 'services/api/thunks/session'; +import { ImageDTO } from 'services/api/types'; import calculateCoordinates from '../util/calculateCoordinates'; import calculateScale from '../util/calculateScale'; import { STAGE_PADDING_PERCENTAGE } from '../util/constants'; @@ -28,13 +35,6 @@ import { isCanvasBaseImage, isCanvasMaskLine, } from './canvasTypes'; -import { ImageDTO } from 'services/api/types'; -import { sessionCanceled } from 'services/api/thunks/session'; -import { - setActiveTab, - setShouldUseCanvasBetaLayout, -} from 'features/ui/store/uiSlice'; -import { imageUrlsReceived } from 'services/api/thunks/image'; export const initialLayerState: CanvasLayerState = { objects: [], @@ -240,6 +240,16 @@ export const canvasSlice = createSlice({ state.scaledBoundingBoxDimensions = scaledDimensions; } }, + flipBoundingBoxAxes: (state) => { + const [currWidth, currHeight] = [ + state.boundingBoxDimensions.width, + state.boundingBoxDimensions.height, + ]; + state.boundingBoxDimensions = { + width: currHeight, + height: currWidth, + }; + }, setBoundingBoxCoordinates: (state, action: PayloadAction) => { state.boundingBoxCoordinates = floorCoordinates(action.payload); }, @@ -864,6 +874,15 @@ export const canvasSlice = createSlice({ builder.addCase(setActiveTab, (state, action) => { state.doesCanvasNeedScaling = true; }); + builder.addCase(setAspectRatio, (state, action) => { + const ratio = action.payload; + if (ratio) { + state.boundingBoxDimensions.height = roundToMultiple( + state.boundingBoxDimensions.width / ratio, + 64 + ); + } + }); // builder.addCase(imageUrlsReceived.fulfilled, (state, action) => { // const { image_name, image_url, thumbnail_url } = action.payload; @@ -912,6 +931,7 @@ export const { setBoundingBoxDimensions, setBoundingBoxPreviewFill, setBoundingBoxScaleMethod, + flipBoundingBoxAxes, setBrushColor, setBrushSize, setCanvasContainerDimensions, diff --git a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx index c6aa04bd24..7b0718182b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx @@ -10,7 +10,6 @@ import { OnInit, OnNodesChange, ReactFlow, - ReactFlowInstance, } from 'reactflow'; import { connectionEnded, @@ -18,6 +17,7 @@ import { connectionStarted, edgesChanged, nodesChanged, + setEditorInstance, } from '../store/nodesSlice'; import { InvocationComponent } from './InvocationComponent'; import ProgressImageNode from './ProgressImageNode'; @@ -69,11 +69,13 @@ export const Flow = () => { dispatch(connectionEnded()); }, [dispatch]); - const onInit: OnInit = useCallback((v: ReactFlowInstance) => { - if (v) { - v.fitView(); - } - }, []); + const onInit: OnInit = useCallback( + (v) => { + dispatch(setEditorInstance(v)); + if (v) v.fitView(); + }, + [dispatch] + ); return ( { return ( @@ -13,6 +14,8 @@ const TopCenterPanel = () => { + + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx new file mode 100644 index 0000000000..10aecc9fcc --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx @@ -0,0 +1,79 @@ +import { FileButton } from '@mantine/core'; +import { makeToast } from 'app/components/Toaster'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { loadFileEdges, loadFileNodes } from 'features/nodes/store/nodesSlice'; +import { addToast } from 'features/system/store/systemSlice'; +import { memo, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaUpload } from 'react-icons/fa'; +import { useReactFlow } from 'reactflow'; + +const LoadNodesButton = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { fitView } = useReactFlow(); + + const uploadedFileRef = useRef<() => void>(null); + + const restoreJSONToEditor = useCallback( + (v: File | null) => { + if (!v) return; + const reader = new FileReader(); + reader.onload = async () => { + const json = reader.result; + const retrievedNodeTree = await JSON.parse(String(json)); + + if (!retrievedNodeTree) { + dispatch( + addToast( + makeToast({ + title: t('toast.nodesLoadedFailed'), + status: 'error', + }) + ) + ); + } + + if (retrievedNodeTree) { + dispatch(loadFileNodes(retrievedNodeTree.nodes)); + dispatch(loadFileEdges(retrievedNodeTree.edges)); + fitView(); + + dispatch( + addToast( + makeToast({ title: t('toast.nodesLoaded'), status: 'success' }) + ) + ); + } + + // Cleanup + reader.abort(); + }; + + reader.readAsText(v); + + // Cleanup + uploadedFileRef.current?.(); + }, + [fitView, dispatch, t] + ); + return ( + + {(props) => ( + } + tooltip={t('nodes.loadNodes')} + aria-label={t('nodes.loadNodes')} + {...props} + /> + )} + + ); +}; + +export default memo(LoadNodesButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx new file mode 100644 index 0000000000..14bf0a1ce8 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx @@ -0,0 +1,45 @@ +import { RootState } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { map, omit } from 'lodash-es'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaSave } from 'react-icons/fa'; + +const SaveNodesButton = () => { + const { t } = useTranslation(); + const editorInstance = useAppSelector( + (state: RootState) => state.nodes.editorInstance + ); + + const saveEditorToJSON = useCallback(() => { + if (editorInstance) { + const editorState = editorInstance.toObject(); + + editorState.edges = map(editorState.edges, (edge) => { + return omit(edge, ['style']); + }); + + const nodeSetupJSON = new Blob([JSON.stringify(editorState)]); + const nodeDownloadElement = document.createElement('a'); + nodeDownloadElement.href = URL.createObjectURL(nodeSetupJSON); + nodeDownloadElement.download = 'MyNodes.json'; + document.body.appendChild(nodeDownloadElement); + nodeDownloadElement.click(); + // Cleanup + nodeDownloadElement.remove(); + } + }, [editorInstance]); + + return ( + } + fontSize={18} + tooltip={t('nodes.saveNodes')} + aria-label={t('nodes.saveNodes')} + onClick={saveEditorToJSON} + /> + ); +}; + +export default memo(SaveNodesButton); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 4fa69c626b..094a43b944 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -13,6 +13,7 @@ import { Node, NodeChange, OnConnectStartParams, + ReactFlowInstance, } from 'reactflow'; import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { ImageField } from 'services/api/types'; @@ -25,6 +26,7 @@ export type NodesState = { invocationTemplates: Record; connectionStartParams: OnConnectStartParams | null; shouldShowGraphOverlay: boolean; + editorInstance: ReactFlowInstance | undefined; }; export const initialNodesState: NodesState = { @@ -34,6 +36,7 @@ export const initialNodesState: NodesState = { invocationTemplates: {}, connectionStartParams: null, shouldShowGraphOverlay: false, + editorInstance: undefined, }; const nodesSlice = createSlice({ @@ -121,6 +124,15 @@ const nodesSlice = createSlice({ nodeEditorReset: () => { return { ...initialNodesState }; }, + setEditorInstance: (state, action) => { + state.editorInstance = action.payload; + }, + loadFileNodes: (state, action: PayloadAction[]>) => { + state.nodes = action.payload; + }, + loadFileEdges: (state, action: PayloadAction) => { + state.edges = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => { @@ -141,6 +153,9 @@ export const { nodeTemplatesBuilt, nodeEditorReset, imageCollectionFieldValueChanged, + setEditorInstance, + loadFileNodes, + loadFileEdges, } = nodesSlice.actions; export default nodesSlice.reducer; diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxHeight.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxHeight.tsx index b529c87225..12fc4abcf7 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxHeight.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxHeight.tsx @@ -2,22 +2,26 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAISlider from 'common/components/IAISlider'; +import { roundToMultiple } from 'common/util/roundDownToMultiple'; import { canvasSelector, isStagingSelector, } from 'features/canvas/store/canvasSelectors'; import { setBoundingBoxDimensions } from 'features/canvas/store/canvasSlice'; +import { uiSelector } from 'features/ui/store/uiSelectors'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; const selector = createSelector( - [canvasSelector, isStagingSelector], - (canvas, isStaging) => { + [canvasSelector, isStagingSelector, uiSelector], + (canvas, isStaging, ui) => { const { boundingBoxDimensions } = canvas; + const { aspectRatio } = ui; return { boundingBoxDimensions, isStaging, + aspectRatio, }; }, defaultSelectorOptions @@ -25,7 +29,8 @@ const selector = createSelector( const ParamBoundingBoxWidth = () => { const dispatch = useAppDispatch(); - const { boundingBoxDimensions, isStaging } = useAppSelector(selector); + const { boundingBoxDimensions, isStaging, aspectRatio } = + useAppSelector(selector); const { t } = useTranslation(); @@ -36,6 +41,15 @@ const ParamBoundingBoxWidth = () => { height: Math.floor(v), }) ); + if (aspectRatio) { + const newWidth = roundToMultiple(v * aspectRatio, 64); + dispatch( + setBoundingBoxDimensions({ + width: newWidth, + height: Math.floor(v), + }) + ); + } }; const handleResetHeight = () => { @@ -45,6 +59,15 @@ const ParamBoundingBoxWidth = () => { height: Math.floor(512), }) ); + if (aspectRatio) { + const newWidth = roundToMultiple(512 * aspectRatio, 64); + dispatch( + setBoundingBoxDimensions({ + width: newWidth, + height: Math.floor(512), + }) + ); + } }; return ( diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxSize.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxSize.tsx new file mode 100644 index 0000000000..3c07768461 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxSize.tsx @@ -0,0 +1,57 @@ +import { Flex, Spacer, Text } from '@chakra-ui/react'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { flipBoundingBoxAxes } from 'features/canvas/store/canvasSlice'; +import { useTranslation } from 'react-i18next'; +import { MdOutlineSwapVert } from 'react-icons/md'; +import ParamAspectRatio from '../../Core/ParamAspectRatio'; +import ParamBoundingBoxHeight from './ParamBoundingBoxHeight'; +import ParamBoundingBoxWidth from './ParamBoundingBoxWidth'; + +export default function ParamBoundingBoxSize() { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + return ( + + + + {t('parameters.aspectRatio')} + + + + } + fontSize={20} + onClick={() => dispatch(flipBoundingBoxAxes())} + /> + + + + + ); +} diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxWidth.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxWidth.tsx index 07d74049ed..5c9a82110a 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxWidth.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxWidth.tsx @@ -2,22 +2,26 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAISlider from 'common/components/IAISlider'; +import { roundToMultiple } from 'common/util/roundDownToMultiple'; import { canvasSelector, isStagingSelector, } from 'features/canvas/store/canvasSelectors'; import { setBoundingBoxDimensions } from 'features/canvas/store/canvasSlice'; +import { uiSelector } from 'features/ui/store/uiSelectors'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; const selector = createSelector( - [canvasSelector, isStagingSelector], - (canvas, isStaging) => { + [canvasSelector, isStagingSelector, uiSelector], + (canvas, isStaging, ui) => { const { boundingBoxDimensions } = canvas; + const { aspectRatio } = ui; return { boundingBoxDimensions, isStaging, + aspectRatio, }; }, defaultSelectorOptions @@ -25,7 +29,8 @@ const selector = createSelector( const ParamBoundingBoxWidth = () => { const dispatch = useAppDispatch(); - const { boundingBoxDimensions, isStaging } = useAppSelector(selector); + const { boundingBoxDimensions, isStaging, aspectRatio } = + useAppSelector(selector); const { t } = useTranslation(); @@ -36,6 +41,15 @@ const ParamBoundingBoxWidth = () => { width: Math.floor(v), }) ); + if (aspectRatio) { + const newHeight = roundToMultiple(v / aspectRatio, 64); + dispatch( + setBoundingBoxDimensions({ + width: Math.floor(v), + height: newHeight, + }) + ); + } }; const handleResetWidth = () => { @@ -45,6 +59,15 @@ const ParamBoundingBoxWidth = () => { width: Math.floor(512), }) ); + if (aspectRatio) { + const newHeight = roundToMultiple(512 / aspectRatio, 64); + dispatch( + setBoundingBoxDimensions({ + width: Math.floor(512), + height: newHeight, + }) + ); + } }; return ( diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasCoreParameters.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasCoreParameters.tsx index 6ea9d4bc8d..6e4ce7d5d0 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasCoreParameters.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasCoreParameters.tsx @@ -4,8 +4,7 @@ import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAICollapse from 'common/components/IAICollapse'; -import ParamBoundingBoxHeight from 'features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxHeight'; -import ParamBoundingBoxWidth from 'features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxWidth'; +import ParamBoundingBoxSize from 'features/parameters/components/Parameters/Canvas/BoundingBox/ParamBoundingBoxSize'; import ParamCFGScale from 'features/parameters/components/Parameters/Core/ParamCFGScale'; import ParamIterations from 'features/parameters/components/Parameters/Core/ParamIterations'; import ParamModelandVAEandScheduler from 'features/parameters/components/Parameters/Core/ParamModelandVAEandScheduler'; @@ -51,8 +50,7 @@ const UnifiedCanvasCoreParameters = () => { - - + ) : ( <> @@ -65,8 +63,7 @@ const UnifiedCanvasCoreParameters = () => { - - + )}