diff --git a/src/lib/components/ComfyWorkflowsView.svelte b/src/lib/components/ComfyWorkflowsView.svelte index 783bacb..ef909d2 100644 --- a/src/lib/components/ComfyWorkflowsView.svelte +++ b/src/lib/components/ComfyWorkflowsView.svelte @@ -51,7 +51,7 @@ } async function doRefreshCombos() { - await app.refreshComboInNodes(undefined, true) + await app.refreshComboInNodes(undefined, undefined, true) } function refreshView(event?: Event) { diff --git a/src/lib/convertVanillaWorkflow.ts b/src/lib/convertVanillaWorkflow.ts index ddd8075..3388f81 100644 --- a/src/lib/convertVanillaWorkflow.ts +++ b/src/lib/convertVanillaWorkflow.ts @@ -1,10 +1,10 @@ -import { LGraph, type INodeInputSlot, type SerializedLGraph, type LinkID, type UUID, type NodeID, LiteGraph, BuiltInSlotType, type SerializedLGraphNode, type Vector2, BuiltInSlotShape, type INodeOutputSlot } from "@litegraph-ts/core"; +import { LGraph, type INodeInputSlot, type SerializedLGraph, type LinkID, type UUID, type NodeID, LiteGraph, BuiltInSlotType, type SerializedLGraphNode, type Vector2, BuiltInSlotShape, type INodeOutputSlot, type SlotType } from "@litegraph-ts/core"; import type { SerializedAppState } from "./components/ComfyApp"; import layoutStates, { defaultWorkflowAttributes, type ContainerLayout, type DragItemID, type SerializedDragEntry, type SerializedLayoutState, type WritableLayoutStateStore } from "./stores/layoutStates"; import { ComfyWorkflow, type WorkflowAttributes } from "./stores/workflowState"; import type { SerializedGraphCanvasState } from "./ComfyGraphCanvas"; import ComfyApp from "./components/ComfyApp"; -import { iterateNodeDefInputs } from "./ComfyNodeDef"; +import { iterateNodeDefInputs, type ComfyNodeDefInputType, type ComfyNodeDefInputOptions } from "./ComfyNodeDef"; import type { ComfyNodeDefInput } from "./ComfyNodeDef"; import type IComfyInputSlot from "./IComfyInputSlot"; import ComfyWidgets from "./widgets" @@ -28,10 +28,20 @@ type ComfyUIConvertedWidget = { config: ComfyNodeDefInput } +/* + * Input slot for widgets converted to inputs + */ interface IComfyUINodeInputSlot extends INodeInputSlot { widget?: ComfyUIConvertedWidget } +/* + * Output slot for PrimitiveNode + */ +interface IComfyUINodeOutputSlot extends INodeOutputSlot { + widget?: ComfyUIConvertedWidget +} + /* * ComfyUI frontend nodes that should be converted directly to another type. */ @@ -158,6 +168,168 @@ function rewriteIDsInGraph(vanillaWorkflow: ComfyVanillaWorkflow) { } } +/* + * Returns [nodeType, inputType] for a config type, like "FLOAT" -> ["ui/number", "number"] + */ +function getWidgetTypesFromConfig(inputType: ComfyNodeDefInputType): [string, SlotType] | null { + let widgetNodeType = null; + let widgetInputType = null; + + if (Array.isArray(inputType)) { + // Combo options of string[] + widgetNodeType = "ui/combo"; + widgetInputType = "string" + } + else if (inputType in ComfyWidgets) { + // Widget type + const widgetFactory = ComfyWidgets[inputType] + widgetNodeType = widgetFactory.nodeType; + widgetInputType = widgetFactory.inputType + } + else if ("${inputType}:{inputName}" in ComfyWidgets) { + // Widget type override for input of type with given name ("seed", "noise_seed") + const widgetFactory = ComfyWidgets["${inputType}:{inputName}"] + widgetNodeType = widgetFactory.nodeType; + widgetInputType = widgetFactory.inputType + } + else { + // Backend type, we can safely ignore this + return null; + } + + return [widgetNodeType, widgetInputType] +} + +function configureWidgetNodeProperties(serWidgetNode: SerializedComfyWidgetNode, inputOpts?: ComfyNodeDefInputOptions) { + inputOpts ||= {} + switch (serWidgetNode.type) { + case "ui/number": + serWidgetNode.properties.min = inputOpts.min || 0; + serWidgetNode.properties.max = inputOpts.max || 100; + serWidgetNode.properties.step = inputOpts.step || 1; + break; + case "ui/text": + serWidgetNode.properties.multiline = inputOpts.multiline || false; + break; + } +} + +/* + * Attempts to convert a primitive node + * The primitive node should be pruned from the graph afterwards + */ +function convertPrimitiveNode(vanillaWorkflow: ComfyVanillaWorkflow, node: SerializedLGraphNode, layoutState: WritableLayoutStateStore, group: ContainerLayout): boolean { + // Get the value output + // On primitive nodes it's the one in the first slot + const mainOutput = (node.outputs || [])[0] as IComfyUINodeOutputSlot; + if (!mainOutput || !mainOutput.links) { + console.error("PrimitiveNode output had no output with links!", node) + return false; + } + + const widget = mainOutput.widget; + if (widget === null) { + console.error("PrimitiveNode output had no widget config!", node) + return false; + } + + const [widgetType, widgetOpts] = widget.config + + if (!node.widgets_values) { + console.error("PrimitiveNode had no serialized widget values!", node) + return false; + } + + let pair = getWidgetTypesFromConfig(widgetType); + if (pair == null) { + // This should never happen! Primitive nodes only deal with frontend types! + console.error("PrimitiveNode had a backend type configured!", node) + return false; + } + + let [widgetNodeType, widgetInputType] = pair + + // PrimitiveNode will have a widget in the first slot with the actual value. + // The rest are configuration values for e.g. seed action onprompt queue. + const value = node.widgets_values[0]; + + const [comfyWidgetNode, serWidgetNode] = createSerializedWidgetNode( + vanillaWorkflow, + node, + 0, // first output on the PrimitiveNode + false, // this is an output slot index + widgetNodeType, + value); + + configureWidgetNodeProperties(serWidgetNode, widgetOpts) + + let foundTitle = null; + const widgetLayout = layoutState.addWidget(group, comfyWidgetNode) + widgetLayout.attrs.title = mainOutput.name; + + // Rewrite links to point to the new widget node + const newLinkOutputSlot = serWidgetNode.outputs.findIndex(o => o.name === comfyWidgetNode.outputSlotName) + if (newLinkOutputSlot !== -1) { + const newLinkOutput = serWidgetNode.outputs[newLinkOutputSlot]; + // TODO other links need pruning? + for (const linkID of mainOutput.links) { + const link = vanillaWorkflow.links.find(l => l[0] === linkID) + if (link) { + link[1] = serWidgetNode.id; // origin node ID + link[2] = newLinkOutputSlot; // origin node slot + newLinkOutput.links ||= [] + newLinkOutput.links.push(linkID) + + // Change the title of the widget to the name of the first input connected to + if (foundTitle == null) { + const targetNode = vanillaWorkflow.nodes.find(n => n.id === link[3]) // target node ID + if (targetNode != null) { + const foundInput = targetNode.inputs[link[4]] // target node slot + if (foundInput != null && foundInput.name) { + foundTitle = foundInput.name; + widgetLayout.attrs.title = foundTitle; + } + } + } + } + } + // Remove links on the old node so they won't be double-removed when it's pruned + mainOutput.links = [] + } + else { + console.error("Could not find output slot for new widget node!", comfyWidgetNode, serWidgetNode) + } + + return true; +} + +function removeSerializedNode(vanillaWorkflow: SerializedLGraph, node: SerializedLGraphNode) { + if (node.outputs) { + for (const output of node.outputs) { + if (output.links) { + vanillaWorkflow.links = vanillaWorkflow.links.filter(l => output.links.indexOf(l[0]) === -1); + output.links = [] + } + } + } + if (node.inputs) { + for (const input of node.inputs) { + if (input.link) { + vanillaWorkflow.links = vanillaWorkflow.links.filter(l => input.link !== l[0]); + input.link = null; + } + } + } + + vanillaWorkflow.nodes = vanillaWorkflow.nodes.filter(n => n.id !== node.id); +} + +/* + * Converts a workflow saved with vanilla ComfyUI into a ComfyBox workflow, + * adding UI nodes for each widget. + * + * TODO: test this! + */ export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWorkflow, attrs: WorkflowAttributes): [ComfyWorkflow, WritableLayoutStateStore] { const [comfyBoxWorkflow, layoutState] = ComfyWorkflow.create(); const { root, left, right } = layoutState.initDefaultLayout(); @@ -186,9 +358,17 @@ export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWork // serialized widgets into ComfyWidgetNodes, add new inputs/outputs, // then attach the new nodes to the slots + // Primitive nodes are special since they can interface with converted + // widget inputs + if (node.type === "PrimitiveNode") { + convertPrimitiveNode(vanillaWorkflow, node, layoutState, left) + removeSerializedNode(vanillaWorkflow, node); + continue + } + const def = ComfyApp.knownBackendNodes[node.type]; if (def == null) { - console.error("Unknown backend node", node.type) + console.error("[convertVanillaWorkflow] Unknown backend node", node.type) continue; } @@ -213,31 +393,14 @@ export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWork // This input is a widget, it should be converted to an input // connected to a ComfyWidgetNode. - let widgetNodeType = null; - let widgetInputType = null; - - if (Array.isArray(inputType)) { - // Combo options of string[] - widgetInputType = "string" - widgetNodeType = "ui/combo"; - } - else if (inputType in ComfyWidgets) { - // Widget type - const widgetFactory = ComfyWidgets[inputType] - widgetInputType = widgetFactory.inputType - widgetNodeType = widgetFactory.nodeType; - } - else if ("${inputType}:{inputName}" in ComfyWidgets) { - // Widget type override for input of type with given name ("seed", "noise_seed") - const widgetFactory = ComfyWidgets["${inputType}:{inputName}"] - widgetInputType = widgetFactory.inputType - widgetNodeType = widgetFactory.nodeType; - } - else { - // Backend type, we can safely ignore this + let pair = getWidgetTypesFromConfig(inputType); + if (pair == null) { + // Input type is backend-only, we can skip adding a UI node here continue } + let [widgetNodeType, widgetInputType] = pair + const newInput: IComfyInputSlot = { name: inputName, link: null, @@ -264,16 +427,7 @@ export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWork widgetNodeType, value); - switch (widgetNodeType) { - case "ui/number": - serWidgetNode.properties.min = inputOpts?.min || 0; - serWidgetNode.properties.max = inputOpts?.max || 100; - serWidgetNode.properties.step = inputOpts?.step || 1; - break; - case "ui/text": - serWidgetNode.properties.multiline = inputOpts?.multiline || false; - break; - } + configureWidgetNodeProperties(serWidgetNode, inputOpts) if (group == null) group = layoutState.addContainer(isOutputNode ? right : left, { title: node.title || node.type }) diff --git a/src/tests/data/convertedWidgetAndPrimitiveNode.json b/src/tests/data/convertedWidgetAndPrimitiveNode.json new file mode 100644 index 0000000..6257fed --- /dev/null +++ b/src/tests/data/convertedWidgetAndPrimitiveNode.json @@ -0,0 +1,135 @@ +{ + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 1, + "type": "KSampler", + "pos": [ + 843, + 567 + ], + "size": [ + 315, + 262 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": null + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": null + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": null + }, + { + "name": "latent_image", + "type": "LATENT", + "link": null + }, + { + "name": "cfg", + "type": "FLOAT", + "link": 1, + "widget": { + "name": "cfg", + "config": [ + "FLOAT", + { + "default": 8, + "min": 0, + "max": 100 + } + ] + }, + "slot_index": 4 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": null, + "shape": 3 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ] + }, + { + "id": 2, + "type": "PrimitiveNode", + "pos": [ + 506, + 637 + ], + "size": { + "0": 210, + "1": 82 + }, + "flags": {}, + "order": 0, + "mode": 0, + "outputs": [ + { + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 1 + ], + "slot_index": 0, + "widget": { + "name": "cfg", + "config": [ + "FLOAT", + { + "default": 8, + "min": 0, + "max": 100 + } + ] + } + } + ], + "properties": {}, + "widgets_values": [ + 8, + "fixed" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 1, + 4, + "FLOAT" + ] + ], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4 +} \ No newline at end of file