From b58494c42071dfd026d13699bc38b1950d87474a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 9 Apr 2024 10:17:03 +1000 Subject: [PATCH] feat(ui): add graph-to-workflow debug helper This is intended for debug usage, so it's hidden away in the workflow library `...` menu. Hold shift to see the button for it. - Paste a graph (from a network request, for example) and then click the convert button to convert it to a workflow. - Disable auto layout to stack the nodes with an offset (try it out). If you change this, you must re-convert to get the changes. - Edit the workflow JSON if you need to tweak something before loading it. --- invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 9 ++ invokeai/frontend/web/public/locales/en.json | 7 +- .../features/nodes/components/NodeEditor.tsx | 2 + .../nodes/util/workflow/graphToWorkflow.ts | 148 ++++++++++++++++++ .../LoadWorkflowFromGraphModal.tsx | 111 +++++++++++++ .../LoadWorkflowFromGraphMenuItem.tsx | 18 +++ .../WorkflowLibraryMenu.tsx | 5 + 8 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/LoadWorkflowFromGraphMenuItem.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 81fc9c4dd3..a99910c549 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@chakra-ui/react-use-size": "^2.1.0", + "@dagrejs/dagre": "^1.1.1", "@dagrejs/graphlib": "^2.2.1", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 4be16619ec..bf423c3d46 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@chakra-ui/react-use-size': specifier: ^2.1.0 version: 2.1.0(react@18.2.0) + '@dagrejs/dagre': + specifier: ^1.1.1 + version: 1.1.1 '@dagrejs/graphlib': specifier: ^2.2.1 version: 2.2.1 @@ -3092,6 +3095,12 @@ packages: dev: true optional: true + /@dagrejs/dagre@1.1.1: + resolution: {integrity: sha512-AQfT6pffEuPE32weFzhS/u3UpX+bRXUARIXL7UqLaxz497cN8pjuBlX6axO4IIECE2gBV8eLFQkGCtKX5sDaUA==} + dependencies: + '@dagrejs/graphlib': 2.2.1 + dev: false + /@dagrejs/graphlib@2.2.1: resolution: {integrity: sha512-xJsN1v6OAxXk6jmNdM+OS/bBE8nDCwM0yDNprXR18ZNatL6to9ggod9+l2XtiLhXfLm0NkE7+Er/cpdlM+SkUA==} engines: {node: '>17.0.0'} diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 9686f8a02a..5454c72e68 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -849,6 +849,7 @@ "version": "Version", "versionUnknown": " Version Unknown", "workflow": "Workflow", + "graph": "Graph", "workflowAuthor": "Author", "workflowContact": "Contact", "workflowDescription": "Short Description", @@ -1482,7 +1483,11 @@ "workflowName": "Workflow Name", "newWorkflowCreated": "New Workflow Created", "workflowCleared": "Workflow Cleared", - "workflowEditorMenu": "Workflow Editor Menu" + "workflowEditorMenu": "Workflow Editor Menu", + "loadFromGraph": "Load Workflow from Graph", + "convertGraph": "Convert Graph", + "loadWorkflow": "$t(common.load) Workflow", + "autoLayout": "Auto Layout" }, "app": { "storeNotInitialized": "Store is not initialized" diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx index 8307997ff9..737adb52e7 100644 --- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx @@ -3,6 +3,7 @@ import 'reactflow/dist/style.css'; import { Flex } from '@invoke-ai/ui-library'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel'; +import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal'; import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; @@ -61,6 +62,7 @@ const NodeEditor = () => { + )} diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts new file mode 100644 index 0000000000..eec9c6cf4b --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/graphToWorkflow.ts @@ -0,0 +1,148 @@ +import * as dagre from '@dagrejs/dagre'; +import { logger } from 'app/logging/logger'; +import { getStore } from 'app/store/nanostores/store'; +import { NODE_WIDTH } from 'features/nodes/types/constants'; +import type { FieldInputInstance } from 'features/nodes/types/field'; +import type { WorkflowV3 } from 'features/nodes/types/workflow'; +import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance'; +import { t } from 'i18next'; +import { forEach } from 'lodash-es'; +import type { NonNullableGraph } from 'services/api/types'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Converts a graph to a workflow. This is a best-effort conversion and may not be perfect. + * For example, if a graph references an unknown node type, that node will be skipped. + * @param graph The graph to convert to a workflow + * @param autoLayout Whether to auto-layout the nodes using `dagre`. If false, nodes will be simply stacked on top of one another with an offset. + * @returns The workflow. + */ +export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): WorkflowV3 => { + const invocationTemplates = getStore().getState().nodes.templates; + + if (!invocationTemplates) { + throw new Error(t('app.storeNotInitialized')); + } + + // Initialize the workflow + const workflow: WorkflowV3 = { + name: '', + author: '', + contact: '', + description: '', + meta: { + category: 'user', + version: '3.0.0', + }, + notes: '', + tags: '', + version: '', + exposedFields: [], + edges: [], + nodes: [], + }; + + // Convert nodes + forEach(graph.nodes, (node) => { + const template = invocationTemplates[node.type]; + + // Skip missing node templates - this is a best-effort + if (!template) { + logger('nodes').warn(`Node type ${node.type} not found in invocationTemplates`); + return; + } + + // Build field input instances for each attr + const inputs: Record = {}; + + forEach(node, (value, key) => { + // Ignore the non-input keys - I think this is all of them? + if (key === 'id' || key === 'type' || key === 'is_intermediate' || key === 'use_cache') { + return; + } + + const inputTemplate = template.inputs[key]; + + // Skip missing input templates + if (!inputTemplate) { + logger('nodes').warn(`Input ${key} not found in template for node type ${node.type}`); + return; + } + + // This _should_ be all we need to do! + const inputInstance = buildFieldInputInstance(node.id, inputTemplate); + inputInstance.value = value; + inputs[key] = inputInstance; + }); + + workflow.nodes.push({ + id: node.id, + type: 'invocation', + position: { x: 0, y: 0 }, // we'll do layout later, just need something here + data: { + id: node.id, + type: node.type, + version: template.version, + label: '', + notes: '', + isOpen: true, + isIntermediate: node.is_intermediate ?? false, + useCache: node.use_cache ?? true, + inputs, + }, + }); + }); + + forEach(graph.edges, (edge) => { + workflow.edges.push({ + id: uuidv4(), // we don't have edge IDs in the graph + type: 'default', + source: edge.source.node_id, + sourceHandle: edge.source.field, + target: edge.destination.node_id, + targetHandle: edge.destination.field, + }); + }); + + if (autoLayout) { + // Best-effort auto layout via dagre - not perfect but better than nothing + const dagreGraph = new dagre.graphlib.Graph(); + // `rankdir` and `align` could be tweaked, but it's gonna be janky no matter what we choose + dagreGraph.setGraph({ rankdir: 'TB', align: 'UL' }); + dagreGraph.setDefaultEdgeLabel(() => ({})); + + // We don't know the dimensions of the nodes until we load the graph into `reactflow` - use a reasonable value + forEach(graph.nodes, (node) => { + const width = NODE_WIDTH; + const height = NODE_WIDTH * 1.5; + dagreGraph.setNode(node.id, { width, height }); + }); + + graph.edges.forEach((edge) => { + dagreGraph.setEdge(edge.source.node_id, edge.destination.node_id); + }); + + // This does the magic + dagre.layout(dagreGraph); + + // Update the workflow now that we've got the positions + workflow.nodes.forEach((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + node.position = { + x: nodeWithPosition.x - nodeWithPosition.width / 2, + y: nodeWithPosition.y - nodeWithPosition.height / 2, + }; + }); + } else { + // Stack nodes with a 50px,50px offset from the previous ndoe + let x = 0; + let y = 0; + workflow.nodes.forEach((node) => { + node.position = { x, y }; + x = x + 50; + y = y + 50; + }); + } + + return workflow; +}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx new file mode 100644 index 0000000000..ecb4aa7dd4 --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx @@ -0,0 +1,111 @@ +import { + Button, + Checkbox, + Flex, + FormControl, + FormLabel, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Spacer, + Textarea, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { workflowLoadRequested } from 'features/nodes/store/actions'; +import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow'; +import { atom } from 'nanostores'; +import type { ChangeEvent } from 'react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const $isOpen = atom(false); + +export const useLoadWorkflowFromGraphModal = () => { + const isOpen = useStore($isOpen); + const onOpen = useCallback(() => { + $isOpen.set(true); + }, []); + const onClose = useCallback(() => { + $isOpen.set(false); + }, []); + + return { isOpen, onOpen, onClose }; +}; + +export const LoadWorkflowFromGraphModal = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { isOpen, onClose } = useLoadWorkflowFromGraphModal(); + const [graphRaw, setGraphRaw] = useState(''); + const [workflowRaw, setWorkflowRaw] = useState(''); + const [shouldAutoLayout, setShouldAutoLayout] = useState(true); + const onChangeGraphRaw = useCallback((e: ChangeEvent) => { + setGraphRaw(e.target.value); + }, []); + const onChangeWorkflowRaw = useCallback((e: ChangeEvent) => { + setWorkflowRaw(e.target.value); + }, []); + const onChangeShouldAutoLayout = useCallback((e: ChangeEvent) => { + setShouldAutoLayout(e.target.checked); + }, []); + const parse = useCallback(() => { + const graph = JSON.parse(graphRaw); + const workflow = graphToWorkflow(graph, shouldAutoLayout); + setWorkflowRaw(JSON.stringify(workflow, null, 2)); + }, [graphRaw, shouldAutoLayout]); + const loadWorkflow = useCallback(() => { + const workflow = JSON.parse(workflowRaw); + dispatch(workflowLoadRequested({ workflow, asCopy: true })); + onClose(); + }, [dispatch, onClose, workflowRaw]); + return ( + + + + {t('workflows.loadFromGraph')} + + + + + + {t('workflows.autoLayout')} + + + + + + + {t('nodes.graph')} +