diff --git a/src/lib/components/BlockContainer.svelte b/src/lib/components/BlockContainer.svelte index dd062f8..05d5882 100644 --- a/src/lib/components/BlockContainer.svelte +++ b/src/lib/components/BlockContainer.svelte @@ -53,8 +53,6 @@ children = layoutState.updateChildren(container, evt.detail.items) // Ensure dragging is stopped on drag finish }; - - const tt = "asd\nasdlkj" {#if container && children} diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index 72636a7..aff7964 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -1,4 +1,4 @@ -import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts } from "@litegraph-ts/core"; +import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts, type SlotIndex } from "@litegraph-ts/core"; import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core"; import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID } from "$lib/api" import { getPngMetadata, importA1111 } from "$lib/pnginfo"; @@ -34,7 +34,7 @@ import notify from "$lib/notify"; import configState from "$lib/stores/configState"; import { blankGraph } from "$lib/defaultGraph"; import type { ComfyExecutionResult } from "$lib/utils"; -import ComfyPromptSerializer from "./ComfyPromptSerializer"; +import ComfyPromptSerializer, { UpstreamNodeLocator, isActiveBackendNode } from "./ComfyPromptSerializer"; import { iterateNodeDefInputs, type ComfyNodeDef, isBackendNodeDefInputType, iterateNodeDefOutputs } from "$lib/ComfyNodeDef"; import { ComfyComboNode } from "$lib/nodes/widgets"; @@ -80,7 +80,7 @@ export type Progress = { type BackendComboNode = { comboNode: ComfyComboNode, - inputSlot: IComfyInputSlot, + comfyInput: IComfyInputSlot, backendNode: ComfyBackendNode } @@ -709,13 +709,13 @@ export default class ComfyApp { async refreshComboInNodes(flashUI: boolean = false) { const defs = await this.api.getNodeDefs(); - const isComfyComboNode = (node: LGraphNode): boolean => { + const isComfyComboNode = (node: LGraphNode): node is ComfyComboNode => { return node && node.type === "ui/combo" && "doAutoConfig" in node; } - const isComfyComboInput = (input: INodeInputSlot) => { + const isComfyComboInput = (input: INodeInputSlot): input is IComfyInputSlot => { return "config" in input && "widgetNodeType" in input && input.widgetNodeType === "ui/combo"; @@ -729,34 +729,42 @@ export default class ComfyApp { // Figure out which combo nodes to update. They need to be connected to // an input slot on a backend node with a backend config in the input // slot connected to. + const nodeLocator = new UpstreamNodeLocator(isComfyComboNode) + + const findComfyInputAndAttachedCombo = (node: LGraphNode, i: SlotIndex): [IComfyInputSlot, ComfyComboNode] | null => { + const input = node.inputs[i] + + // Does this input autocreate a combo box on creation? + const isComfyInput = isComfyComboInput(input) + if (!isComfyInput) + return null; + + // Find an attached combo node even if it's inside/outside of a + // subgraph, linked after several nodes, etc. + const [comboNode, _link] = nodeLocator.locateUpstream(node, i, null); + + if (comboNode == null) + return null; + + const result: [IComfyInputSlot, ComfyComboNode] = [input, comboNode as ComfyComboNode] + return result + } + for (const node of this.lGraph.iterateNodesInOrderRecursive()) { - if (!(node as any).isBackendNode) + if (!isActiveBackendNode(node)) continue; - const backendNode = (node as ComfyBackendNode) - const found = range(backendNode.inputs.length) - .filter(i => { - const input = backendNode.inputs[i] - const inputNode = backendNode.getInputNode(i) + const found = range(node.inputs.length) + .map((i) => findComfyInputAndAttachedCombo(node, i)) + .filter(Boolean); - // Does this input autocreate a combo box on creation? - const isComfyInput = isComfyComboInput(input) - const isComfyCombo = isComfyComboNode(inputNode) + for (const [comfyInput, comboNode] of found) { + const def = defs[node.type]; - // console.debug("[refreshComboInNodes] CHECK", backendNode.type, input.name, "isComfyCombo", isComfyCombo, "isComfyInput", isComfyInput) - - return isComfyCombo && isComfyInput - }); - - for (const inputIndex of found) { - const comboNode = backendNode.getInputNode(inputIndex) as ComfyComboNode - const inputSlot = backendNode.inputs[inputIndex] as IComfyInputSlot; - const def = defs[backendNode.type]; - - const hasBackendConfig = def["input"]["required"][inputSlot.name] !== undefined + const hasBackendConfig = def["input"]["required"][comfyInput.name] !== undefined if (hasBackendConfig) { - backendUpdatedCombos[comboNode.id] = { comboNode, inputSlot, backendNode } + backendUpdatedCombos[comboNode.id] = { comboNode, comfyInput, backendNode: node } } } } @@ -792,12 +800,12 @@ export default class ComfyApp { await tick(); // Load definitions from the backend. - for (const { comboNode, inputSlot, backendNode } of Object.values(backendUpdatedCombos)) { + for (const { comboNode, comfyInput, backendNode } of Object.values(backendUpdatedCombos)) { const def = defs[backendNode.type]; - const rawValues = def["input"]["required"][inputSlot.name][0]; + const rawValues = def["input"]["required"][comfyInput.name][0]; console.debug("[ComfyApp] Reconfiguring combo widget", backendNode.type, "=>", comboNode.type, rawValues.length) - comboNode.doAutoConfig(inputSlot, { includeProperties: new Set(["values"]), setWidgetTitle: false }) + comboNode.doAutoConfig(comfyInput, { includeProperties: new Set(["values"]), setWidgetTitle: false }) comboNode.formatValues(rawValues as string[]) if (!rawValues?.includes(get(comboNode.value))) { diff --git a/src/lib/components/ComfyPromptSerializer.ts b/src/lib/components/ComfyPromptSerializer.ts index b112d4f..7a26fcc 100644 --- a/src/lib/components/ComfyPromptSerializer.ts +++ b/src/lib/components/ComfyPromptSerializer.ts @@ -1,7 +1,7 @@ import type ComfyGraph from "$lib/ComfyGraph"; import type { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; -import { GraphInput, GraphOutput, LGraph, LGraphNode, LLink, NodeMode, Subgraph } from "@litegraph-ts/core"; +import { GraphInput, GraphOutput, LGraph, LGraphNode, LLink, NodeMode, Subgraph, type SlotIndex } from "@litegraph-ts/core"; import type { SerializedPrompt, SerializedPromptInput, SerializedPromptInputs, SerializedPromptInputsAll } from "./ComfyApp"; import type IComfyInputSlot from "$lib/IComfyInputSlot"; @@ -9,8 +9,8 @@ function hasTag(node: LGraphNode, tag: string): boolean { return "tags" in node.properties && node.properties.tags.indexOf(tag) !== -1 } -function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is ComfyBackendNode { - if (!node.isBackendNode) +export function isActiveNode(node: LGraphNode, tag: string | null = null): boolean { + if (!node) return false; if (tag && !hasTag(node, tag)) { @@ -18,7 +18,7 @@ function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is return false; } - if (node.mode === NodeMode.NEVER) { + if (node.mode !== NodeMode.ALWAYS) { // Don't serialize muted nodes return false; } @@ -26,48 +26,122 @@ function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is return true; } -function followSubgraph(subgraph: Subgraph, link: LLink): [LGraph | null, LLink | null] { - if (link.origin_id != subgraph.id) - throw new Error("A!") +export function isActiveBackendNode(node: LGraphNode, tag: string | null = null): node is ComfyBackendNode { + if (!(node as any).isBackendNode) + return false; - const innerGraphOutput = subgraph.getInnerGraphOutputByIndex(link.origin_slot) - if (innerGraphOutput == null) - throw new Error("No inner graph input!") - - const nextLink = innerGraphOutput.getInputLink(0) - return [innerGraphOutput.graph, nextLink]; + return isActiveNode(node, tag); } -function followGraphInput(graphInput: GraphInput, link: LLink): [LGraph | null, LLink | null] { - if (link.origin_id != graphInput.id) - throw new Error("A!") - - const outerSubgraph = graphInput.getParentSubgraph(); - if (outerSubgraph == null) - throw new Error("No outer subgraph!") - - const outerInputIndex = outerSubgraph.inputs.findIndex(i => i.name === graphInput.nameInGraph) - if (outerInputIndex == null) - throw new Error("No outer input slot!") - - const nextLink = outerSubgraph.getInputLink(outerInputIndex) - return [outerSubgraph.graph, nextLink]; -} - -function getUpstreamLink(parent: LGraphNode, currentLink: LLink): [LGraph | null, LLink | null] { - if (parent.is(Subgraph)) { - console.warn("FollowSubgraph") - return followSubgraph(parent, currentLink); +export class UpstreamNodeLocator { + constructor(private isTheTargetNode: (node: LGraphNode) => boolean) { } - else if (parent.is(GraphInput)) { - console.warn("FollowGraphInput") - return followGraphInput(parent, currentLink); + + private followSubgraph(subgraph: Subgraph, link: LLink): [LGraph | null, LLink | null] { + if (link.origin_id != subgraph.id) + throw new Error("Invalid link and graph output!") + + const innerGraphOutput = subgraph.getInnerGraphOutputByIndex(link.origin_slot) + if (innerGraphOutput == null) + throw new Error("No inner graph input!") + + const nextLink = innerGraphOutput.getInputLink(0) + return [innerGraphOutput.graph, nextLink]; } - else if ("getUpstreamLink" in parent) { - return [parent.graph, (parent as ComfyGraphNode).getUpstreamLink()]; + + private followGraphInput(graphInput: GraphInput, link: LLink): [LGraph | null, LLink | null] { + if (link.origin_id != graphInput.id) + throw new Error("Invalid link and graph input!") + + const outerSubgraph = graphInput.getParentSubgraph(); + if (outerSubgraph == null) + throw new Error("No outer subgraph!") + + const outerInputIndex = outerSubgraph.inputs.findIndex(i => i.name === graphInput.nameInGraph) + if (outerInputIndex == null) + throw new Error("No outer input slot!") + + const nextLink = outerSubgraph.getInputLink(outerInputIndex) + return [outerSubgraph.graph, nextLink]; + } + + private getUpstreamLink(parent: LGraphNode, currentLink: LLink): [LGraph | null, LLink | null] { + if (parent.is(Subgraph)) { + console.debug("FollowSubgraph") + return this.followSubgraph(parent, currentLink); + } + else if (parent.is(GraphInput)) { + console.debug("FollowGraphInput") + return this.followGraphInput(parent, currentLink); + } + else if ("getUpstreamLink" in parent) { + return [parent.graph, (parent as ComfyGraphNode).getUpstreamLink()]; + } + else if (parent.inputs.length === 1) { + // Only one input, so assume we can follow it backwards. + const link = parent.getInputLink(0); + if (link) { + return [parent.graph, link] + } + } + console.warn("[graphToPrompt] Frontend node does not support getUpstreamLink", parent.type) + return [null, null]; + } + + /* + * Traverses the graph upstream from outputs towards inputs across + * a sequence of nodes dependent on a condition. + * + * Returns the node and the output link attached to it that leads to the + * starting node if any. + */ + locateUpstream(fromNode: LGraphNode, inputIndex: SlotIndex, tag: string | null): [LGraphNode | null, LLink | null] { + let parent = fromNode.getInputNode(inputIndex); + if (!parent) + return [null, null]; + + const seen = {} + let currentLink = fromNode.getInputLink(inputIndex); + + const shouldFollowParent = (parent: LGraphNode) => { + return isActiveNode(parent, tag) && !this.isTheTargetNode(parent); + } + + // If there are non-target nodes between us and another + // backend node, we have to traverse them first. This + // behavior is dependent on the type of node. Reroute nodes + // will simply follow their single input, while branching + // nodes have conditional logic that determines which link + // to follow backwards. + while (shouldFollowParent(parent)) { + const [nextGraph, nextLink] = this.getUpstreamLink(parent, currentLink); + + if (nextLink == null) { + console.warn("[graphToPrompt] No upstream link found in frontend node", parent) + break; + } + + if (nextLink && !seen[nextLink.id]) { + seen[nextLink.id] = true + const nextParent = nextGraph.getNodeById(nextLink.origin_id); + if (!isActiveNode(parent, tag)) { + parent = null; + } + else { + console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, (nextParent as any)?.isBackendNode) + currentLink = nextLink; + parent = nextParent; + } + } else { + parent = null; + } + } + + if (!isActiveNode(parent, tag) || !this.isTheTargetNode(parent) || currentLink == null) + return [null, null]; + + return [parent, currentLink] } - console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) - return [null, null]; } export default class ComfyPromptSerializer { @@ -129,66 +203,21 @@ export default class ComfyPromptSerializer { serializeBackendLinks(node: ComfyBackendNode, tag: string | null): Record { const inputs = {} + // Find a backend node upstream following before any number of frontend nodes + const test = (node: LGraphNode) => (node as any).isBackendNode + const nodeLocator = new UpstreamNodeLocator(test) + // Store links between backend-only and hybrid nodes for (let i = 0; i < node.inputs.length; i++) { - let parent = node.getInputNode(i); - if (parent) { - const seen = {} - let currentLink = node.getInputLink(i); - - const isFrontendParent = (parent: LGraphNode) => { - if (!parent || (parent as any).isBackendNode) - return false; - if (tag && !hasTag(parent, tag)) - return false; - return true; - } - - // If there are frontend-only nodes between us and another - // backend node, we have to traverse them first. This - // behavior is dependent on the type of node. Reroute nodes - // will simply follow their single input, while branching - // nodes have conditional logic that determines which link - // to follow backwards. - while (isFrontendParent(parent)) { - const [nextGraph, nextLink] = getUpstreamLink(parent, currentLink); - - if (nextLink == null) { - console.warn("[graphToPrompt] No upstream link found in frontend node", parent) - break; - } - - if (nextLink && !seen[nextLink.id]) { - seen[nextLink.id] = true - const nextParent = nextGraph.getNodeById(nextLink.origin_id); - if (nextParent && tag && !hasTag(nextParent, tag)) { - console.debug("[graphToPrompt] Skipping tagged intermediate frontend node", tag, node.properties.tags) - parent = null; - } - else { - console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, (nextParent as any)?.isBackendNode) - currentLink = nextLink; - parent = nextParent; - } - } else { - parent = null; - } - } - - if (currentLink && parent && (parent as any).isBackendNode) { - if (tag && !hasTag(parent, tag)) - continue; - - console.debug("[graphToPrompt] final link", parent.id, node.id) - const input = node.inputs[i] - // TODO can null be a legitimate value in some cases? - // Nodes like CLIPLoader will never have a value in the frontend, hence "null". - if (!(input.name in inputs)) - inputs[input.name] = [String(currentLink.origin_id), currentLink.origin_slot]; - } - else { - console.warn("[graphToPrompt] Didn't find upstream link!", currentLink, parent?.id) - } + const [backendNode, linkLeadingTo] = nodeLocator.locateUpstream(node, i, tag) + if (backendNode) { + console.debug("[graphToPrompt] final link", backendNode.id, "-->", node.id) + const input = node.inputs[i] + if (!(input.name in inputs)) + inputs[input.name] = [String(linkLeadingTo.origin_id), linkLeadingTo.origin_slot]; + } + else { + console.warn("[graphToPrompt] Didn't find upstream link!", node.id, node.type, node.title) } } diff --git a/src/lib/nodes/widgets/ComfyButtonNode.ts b/src/lib/nodes/widgets/ComfyButtonNode.ts new file mode 100644 index 0000000..ed82a65 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyButtonNode.ts @@ -0,0 +1,48 @@ +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import ButtonWidget from "$lib/widgets/ButtonWidget.svelte"; +import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode"; + +export interface ComfyButtonProperties extends ComfyWidgetProperties { + param: string +} + +export default class ComfyButtonNode extends ComfyWidgetNode { + override properties: ComfyButtonProperties = { + tags: [], + defaultValue: false, + param: "bang" + } + + static slotLayout: SlotLayout = { + outputs: [ + { name: "clicked", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = ButtonWidget; + override defaultValue = false; + override outputSlotName = null; + override changedEventName = null; + + constructor(name?: string) { + super(name, false) + } + + override parseValue(param: any): boolean { + return Boolean(param); + } + + onClick() { + this.setValue(true) + this.triggerSlot(0, this.properties.param); + this.setValue(false) // TODO onRelease + } +} + +LiteGraph.registerNodeType({ + class: ComfyButtonNode, + title: "UI.Button", + desc: "Button that triggers an event when clicked", + type: "ui/button" +}) diff --git a/src/lib/nodes/widgets/ComfyCheckboxNode.ts b/src/lib/nodes/widgets/ComfyCheckboxNode.ts new file mode 100644 index 0000000..91aebf6 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyCheckboxNode.ts @@ -0,0 +1,43 @@ +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyCheckboxProperties extends ComfyWidgetProperties { +} + +export default class ComfyCheckboxNode extends ComfyWidgetNode { + override properties: ComfyCheckboxProperties = { + tags: [], + defaultValue: false, + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "boolean" }, + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = CheckboxWidget; + override defaultValue = false; + + constructor(name?: string) { + super(name, false) + } + + override parseValue(param: any) { + return Boolean(param); + } +} + +LiteGraph.registerNodeType({ + class: ComfyCheckboxNode, + title: "UI.Checkbox", + desc: "Checkbox that stores a boolean value", + type: "ui/checkbox" +}) diff --git a/src/lib/nodes/widgets/ComfyComboNode.ts b/src/lib/nodes/widgets/ComfyComboNode.ts new file mode 100644 index 0000000..e44a800 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyComboNode.ts @@ -0,0 +1,154 @@ +import type IComfyInputSlot from "$lib/IComfyInputSlot"; +import { BuiltInSlotType, LiteGraph, type INodeInputSlot, type LGraphNode, type SerializedLGraphNode, type SlotLayout } from "@litegraph-ts/core"; +import { writable, type Writable } from "svelte/store"; + +import ComboWidget from "$lib/widgets/ComboWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + + +export interface ComfyComboProperties extends ComfyWidgetProperties { + values: string[] + + /* JS Function body that takes a parameter named "value" as a parameter and returns the label for each combo entry */ + convertValueToLabelCode: string +} + +export default class ComfyComboNode extends ComfyWidgetNode { + override properties: ComfyComboProperties = { + tags: [], + defaultValue: "A", + values: ["A", "B", "C", "D"], + convertValueToLabelCode: "" + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "string" }, + { name: "changed", type: BuiltInSlotType.EVENT } + ] + } + + override svelteComponentType = ComboWidget + override defaultValue = "A"; + override saveUserState = false; + + // True if at least one combo box refresh has taken place + // Wait until the initial graph load for combo to be valid. + firstLoad: Writable; + valuesForCombo: Writable; // Changed when the combo box has values. + + constructor(name?: string) { + super(name, "A") + this.firstLoad = writable(false) + this.valuesForCombo = writable(null) + } + + override onPropertyChanged(property: any, value: any) { + if (property === "values" || property === "convertValueToLabelCode") { + // this.formatValues(this.properties.values) + } + } + + formatValues(values: string[]) { + if (values == null) + return; + + this.properties.values = values; + + let formatter: any; + if (this.properties.convertValueToLabelCode) + formatter = new Function("value", this.properties.convertValueToLabelCode) as (v: string) => string; + else + formatter = (value: any) => `${value}`; + + let valuesForCombo = [] + + try { + valuesForCombo = this.properties.values.map((value, index) => { + return { + value, + label: formatter(value), + index + } + }) + } + catch (err) { + console.error("Failed formatting!", err) + valuesForCombo = this.properties.values.map((value, index) => { + return { + value, + label: `${value}`, + index + } + }) + } + + this.firstLoad.set(true) + this.valuesForCombo.set(valuesForCombo); + } + + onConnectOutput( + outputIndex: number, + inputType: INodeInputSlot["type"], + input: INodeInputSlot, + inputNode: LGraphNode, + inputIndex: number + ): boolean { + if (!super.onConnectOutput(outputIndex, inputType, input, inputNode, inputIndex)) + return false; + + const thisProps = this.properties; + if (!("config" in input)) + return true; + + const comfyInput = input as IComfyInputSlot; + const otherProps = comfyInput.config; + + // Ensure combo options match + if (!(otherProps.values instanceof Array)) + return false; + if (thisProps.values.find((v, i) => otherProps.values.indexOf(v) === -1)) + return false; + + return true; + } + + override parseValue(value: any): string { + if (typeof value !== "string" || this.properties.values.indexOf(value) === -1) + return this.properties.values[0] + return value + } + + override clampOneConfig(input: IComfyInputSlot) { + if (!input.config.values) + this.setValue("") + else if (input.config.values.indexOf(this.properties.value) === -1) { + if (input.config.values.length === 0) + this.setValue("") + else + this.setValue(input.config.defaultValue || input.config.values[0]) + } + } + + override onSerialize(o: SerializedLGraphNode) { + super.onSerialize(o); + // TODO fix saving combo nodes with huge values lists + o.properties.values = [] + } + + override stripUserState(o: SerializedLGraphNode) { + super.stripUserState(o); + o.properties.values = [] + } +} + +LiteGraph.registerNodeType({ + class: ComfyComboNode, + title: "UI.Combo", + desc: "Combo box outputting a string value", + type: "ui/combo" +}) diff --git a/src/lib/nodes/widgets/ComfyGalleryNode.ts b/src/lib/nodes/widgets/ComfyGalleryNode.ts new file mode 100644 index 0000000..d0b1f5b --- /dev/null +++ b/src/lib/nodes/widgets/ComfyGalleryNode.ts @@ -0,0 +1,99 @@ +import { parseWhateverIntoImageMetadata, type ComfyBoxImageMetadata, type ComfyUploadImageType } from "$lib/utils"; +import { BuiltInSlotType, LiteGraph, type IComboWidget, type ITextWidget, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core"; +import { get } from "svelte/store"; + +import GalleryWidget from "$lib/widgets/GalleryWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyGalleryProperties extends ComfyWidgetProperties { + index: number, + updateMode: "replace" | "append", +} + +export default class ComfyGalleryNode extends ComfyWidgetNode { + override properties: ComfyGalleryProperties = { + tags: [], + defaultValue: [], + index: 0, + updateMode: "replace", + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "images", type: "OUTPUT" }, + { name: "store", type: BuiltInSlotType.ACTION, options: { color_off: "rebeccapurple", color_on: "rebeccapurple" } } + ], + outputs: [ + { name: "images", type: "COMFYBOX_IMAGES" }, + { name: "selected_index", type: "number" }, + ] + } + + static propertyLayout: PropertyLayout = [ + { name: "updateMode", defaultValue: "replace", type: "enum", options: { values: ["replace", "append"] } } + ] + + override svelteComponentType = GalleryWidget + override defaultValue = [] + override saveUserState = false; + override outputSlotName = null; + override changedEventName = null; + + selectedFilename: string | null = null; + + selectedIndexWidget: ITextWidget; + modeWidget: IComboWidget; + + constructor(name?: string) { + super(name, []) + this.selectedIndexWidget = this.addWidget("text", "Selected", String(this.properties.index), "index") + this.selectedIndexWidget.disabled = true; + this.modeWidget = this.addWidget("combo", "Mode", this.properties.updateMode, null, { property: "updateMode", values: ["replace", "append"] }) + } + + override onPropertyChanged(property: any, value: any) { + if (property === "updateMode") { + this.modeWidget.value = value; + } + } + + override onExecute() { + this.setOutputData(0, get(this.value)) + this.setOutputData(1, this.properties.index) + } + + override onAction(action: any, param: any, options: { action_call?: string }) { + super.onAction(action, param, options) + } + + override formatValue(value: ComfyBoxImageMetadata[] | null): string { + return `Images: ${value?.length || 0}` + } + + override parseValue(param: any): ComfyBoxImageMetadata[] { + const meta = parseWhateverIntoImageMetadata(param) || []; + + console.debug("[ComfyGalleryNode] Received output!", param) + + if (this.properties.updateMode === "append") { + const currentValue = get(this.value) + return currentValue.concat(meta) + } + else { + return meta; + } + } + + override setValue(value: any, noChangedEvent: boolean = false) { + super.setValue(value, noChangedEvent) + this.setProperty("index", null) + } +} + +LiteGraph.registerNodeType({ + class: ComfyGalleryNode, + title: "UI.Gallery", + desc: "Gallery that shows most recent outputs", + type: "ui/gallery" +}) diff --git a/src/lib/nodes/widgets/ComfyImageEditorNode.ts b/src/lib/nodes/widgets/ComfyImageEditorNode.ts new file mode 100644 index 0000000..3adbdbb --- /dev/null +++ b/src/lib/nodes/widgets/ComfyImageEditorNode.ts @@ -0,0 +1,51 @@ +import { parseWhateverIntoImageMetadata, type ComfyBoxImageMetadata } from "$lib/utils"; +import type { FileData as GradioFileData } from "@gradio/upload"; +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyImageUploadNodeProperties extends ComfyWidgetProperties { +} + +export default class ComfyImageUploadNode extends ComfyWidgetNode { + properties: ComfyImageUploadNodeProperties = { + defaultValue: [], + tags: [], + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "images", type: "COMFYBOX_IMAGES" }, // TODO support batches + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = ImageUploadWidget; + override defaultValue = []; + override storeActionName = "store"; + override saveUserState = false; + + constructor(name?: string) { + super(name, []) + } + + override parseValue(value: any): ComfyBoxImageMetadata[] { + return parseWhateverIntoImageMetadata(value) || []; + } + + override formatValue(value: GradioFileData[]): string { + return `Images: ${value?.length || 0}` + } +} + +LiteGraph.registerNodeType({ + class: ComfyImageUploadNode, + title: "UI.ImageUpload", + desc: "Widget that lets you upload and edit a multi-layered image. Can also act like a standalone image uploader.", + type: "ui/image_upload" +}) diff --git a/src/lib/nodes/widgets/ComfyNumberNode.ts b/src/lib/nodes/widgets/ComfyNumberNode.ts new file mode 100644 index 0000000..0d6c7af --- /dev/null +++ b/src/lib/nodes/widgets/ComfyNumberNode.ts @@ -0,0 +1,69 @@ +import type IComfyInputSlot from "$lib/IComfyInputSlot"; +import { clamp } from "$lib/utils"; +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import RangeWidget from "$lib/widgets/RangeWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyNumberProperties extends ComfyWidgetProperties { + min: number, + max: number, + step: number, + precision: number +} + +export default class ComfyNumberNode extends ComfyWidgetNode { + override properties: ComfyNumberProperties = { + tags: [], + defaultValue: 0, + min: 0, + max: 10, + step: 1, + precision: 1 + } + + override svelteComponentType = RangeWidget + override defaultValue = 0; + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "number" }, + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override outputProperties = [ + { name: "min", type: "number" }, + { name: "max", type: "number" }, + { name: "step", type: "number" }, + { name: "precision", type: "number" }, + ] + + constructor(name?: string) { + super(name, 0) + } + + override parseValue(value: any): number { + if (typeof value !== "number") + return this.properties.min; + return clamp(value, this.properties.min, this.properties.max) + } + + override clampOneConfig(input: IComfyInputSlot) { + // this.setProperty("min", clamp(this.properties.min, input.config.min, input.config.max)) + // this.setProperty("max", clamp(this.properties.max, input.config.max, input.config.min)) + // this.setProperty("step", Math.min(this.properties.step, input.config.step)) + this.setValue(this.properties.defaultValue) + } +} + +LiteGraph.registerNodeType({ + class: ComfyNumberNode, + title: "UI.Number", + desc: "Displays a number, by default in a slider format.", + type: "ui/number" +}) diff --git a/src/lib/nodes/widgets/ComfyRadioNode.ts b/src/lib/nodes/widgets/ComfyRadioNode.ts new file mode 100644 index 0000000..aee9f86 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyRadioNode.ts @@ -0,0 +1,82 @@ +import { clamp } from "$lib/utils"; +import { BuiltInSlotType, LiteGraph, type INumberWidget, type SlotLayout } from "@litegraph-ts/core"; +import { get } from "svelte/store"; + +import RadioWidget from "$lib/widgets/RadioWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + + +export interface ComfyRadioProperties extends ComfyWidgetProperties { + choices: string[] +} + +export default class ComfyRadioNode extends ComfyWidgetNode { + override properties: ComfyRadioProperties = { + tags: [], + choices: ["Choice A", "Choice B", "Choice C"], + defaultValue: "Choice A", + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "string" }, + { name: "index", type: "number" }, + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = RadioWidget; + override defaultValue = ""; + + indexWidget: INumberWidget; + + index = 0; + + constructor(name?: string) { + super(name, "Choice A") + this.indexWidget = this.addWidget("number", "Index", this.index) + this.indexWidget.disabled = true; + } + + override onExecute(param: any, options: object) { + super.onExecute(param, options); + this.setOutputData(1, this.index) + } + + override setValue(value: string, noChangedEvent: boolean = false) { + super.setValue(value, noChangedEvent) + + value = get(this.value); + + const index = this.properties.choices.indexOf(value) + if (index === -1) + return; + + this.index = index; + this.indexWidget.value = index; + this.setOutputData(1, this.index) + } + + override parseValue(param: any): string { + if (typeof param === "string") { + if (this.properties.choices.indexOf(param) === -1) + return this.properties.choices[0] + return param + } + else { + const index = clamp(parseInt(param), 0, this.properties.choices.length - 1) + return this.properties.choices[index] || this.properties.defaultValue + } + } +} + +LiteGraph.registerNodeType({ + class: ComfyRadioNode, + title: "UI.Radio", + desc: "Radio that outputs a string and index", + type: "ui/radio" +}) diff --git a/src/lib/nodes/widgets/ComfyTextNode.ts b/src/lib/nodes/widgets/ComfyTextNode.ts new file mode 100644 index 0000000..4c62642 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyTextNode.ts @@ -0,0 +1,44 @@ +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import TextWidget from "$lib/widgets/TextWidget.svelte"; +import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode"; + +export interface ComfyTextProperties extends ComfyWidgetProperties { + multiline: boolean; +} + +export default class ComfyTextNode extends ComfyWidgetNode { + override properties: ComfyTextProperties = { + tags: [], + defaultValue: "", + multiline: false + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "string" }, + { name: "changed", type: BuiltInSlotType.EVENT } + ] + } + + override svelteComponentType = TextWidget + override defaultValue = ""; + + constructor(name?: string) { + super(name, "") + } + + override parseValue(value: any): string { + return `${value}` + } +} + +LiteGraph.registerNodeType({ + class: ComfyTextNode, + title: "UI.Text", + desc: "Textbox outputting a string value", + type: "ui/text" +}) diff --git a/src/lib/nodes/widgets/ComfyWidgetNode.ts b/src/lib/nodes/widgets/ComfyWidgetNode.ts new file mode 100644 index 0000000..56709b6 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyWidgetNode.ts @@ -0,0 +1,329 @@ +import type IComfyInputSlot from "$lib/IComfyInputSlot"; +import layoutState from "$lib/stores/layoutState"; +import { range } from "$lib/utils"; +import { LConnectionKind, LGraphCanvas, LLink, LiteGraph, NodeMode, type INodeInputSlot, type INodeOutputSlot, type ITextWidget, type LGraphNode, type SerializedLGraphNode, type Vector2 } from "@litegraph-ts/core"; +import { Watch } from "@litegraph-ts/nodes-basic"; +import type { SvelteComponentDev } from "svelte/internal"; +import { get, writable, type Unsubscriber, type Writable } from "svelte/store"; + +import type { ComfyNodeID } from "$lib/api"; +import type { ComfyGraphNodeProperties } from "../ComfyGraphNode"; +import ComfyGraphNode from "../ComfyGraphNode"; + +export type AutoConfigOptions = { + includeProperties?: Set | null, + setDefaultValue?: boolean + setWidgetTitle?: boolean +} + +/* + * NOTE: If you want to add a new widget but it has the same input/output type + * as another one of the existing widgets, best to create a new "variant" of + * that widget instead. + * + * - Go to layoutState, look for `ALL_ATTRIBUTES,` insert or find a "variant" + * attribute and set `validNodeTypes` to the type of the litegraph node + * - Add a new entry in the `values` array, like "knob" or "dial" for ComfySliderWidget + * - Add an {#if widget.attrs.variant === <...>} statement in the existing Svelte component + * + * Also, BEWARE of calling setOutputData() and triggerSlot() on the same frame! + * You will have to either implement an internal delay on the event triggering + * or use an Event Delay node to ensure the output slot data can propagate to + * the rest of the graph first (see `delayChangedEvent` for details) + */ + +export interface ComfyWidgetProperties extends ComfyGraphNodeProperties { + defaultValue: any +} + +/* + * A node that is tied to a UI widget in the frontend. When the frontend's + * widget is changed, the value of the first output in the node is updated + * in the litegraph instance. + */ +export default abstract class ComfyWidgetNode extends ComfyGraphNode { + abstract properties: ComfyWidgetProperties; + + value: Writable + propsChanged: Writable = writable(0) // dummy to indicate if props changed + unsubscribe: Unsubscriber; + + /** Svelte class for the frontend logic */ + abstract svelteComponentType: typeof SvelteComponentDev + + /** If false, user manually set min/max/step, and should not be autoinherited from connected input */ + autoConfig: boolean = true; + + copyFromInputLink: boolean = true; + + /** + * If true wait until next frame update to trigger the changed event. + * Reason is, if the event is triggered immediately then other stuff that wants to run + * their own onExecute on the output value won't have completed yet. + */ + delayChangedEvent: boolean = true; + + private _aboutToChange: number = 0; + private _aboutToChangeValue: any = null; + private _noChangedEvent: boolean = false; + + abstract defaultValue: T; + + /** Names of properties to add as inputs */ + // shownInputProperties: string[] = [] + + /** Names of properties to add as outputs */ + private shownOutputProperties: Record = {} + outputProperties: { name: string, type: string }[] = [] + + override isBackendNode = false; + override serialize_widgets = true; + + storeActionName: string | null = "store"; + + // output slots + outputSlotName: string | null = "value"; + changedEventName: string | null = "changed"; + + displayWidget: ITextWidget; + + override size: Vector2 = [60, 40]; + + constructor(name: string, value: T) { + const color = LGraphCanvas.node_colors["blue"] + super(name) + this.value = writable(value) + this.color ||= color.color + this.bgColor ||= color.bgColor + this.displayWidget = this.addWidget( + "text", + "Value", + "" + ); + this.displayWidget.disabled = true; // prevent editing + this.unsubscribe = this.value.subscribe(this.onValueUpdated.bind(this)) + } + + addPropertyAsOutput(propertyName: string, type: string) { + if (this.shownOutputProperties["@" + propertyName]) + return; + + if (!(propertyName in this.properties)) { + throw `No property named ${propertyName} found!` + } + + this.shownOutputProperties["@" + propertyName] = { type, index: this.outputs.length } + this.addOutput("@" + propertyName, type) + } + + formatValue(value: any): string { + return Watch.toString(value) + } + + override changeMode(modeTo: NodeMode): boolean { + const result = super.changeMode(modeTo); + this.notifyPropsChanged(); + return result; + } + + private onValueUpdated(value: any) { + // console.debug("[Widget] valueUpdated", this, value) + this.displayWidget.value = this.formatValue(value) + + if (this.outputSlotName !== null) { + const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName) + if (outputIndex !== -1) + this.setOutputData(outputIndex, get(this.value)) + } + + if (this.changedEventName !== null && !this._noChangedEvent) { + if (!this.delayChangedEvent) + this.triggerChangeEvent(get(this.value)) + else { + // console.debug("[Widget] queueChangeEvent", this, value) + this._aboutToChange = 2; // wait 1.5-2 frames, in case we're already in the middle of executing the graph + this._aboutToChangeValue = get(this.value); + } + } + this._noChangedEvent = false; + } + + private triggerChangeEvent(value: any) { + // console.debug("[Widget] trigger changed", this, value) + this.trigger(this.changedEventName, value) + } + + parseValue(value: any): T { return value as T }; + + getValue(): T { + return get(this.value); + } + + setValue(value: any, noChangedEvent: boolean = false) { + if (noChangedEvent) + this._noChangedEvent = true; + + const parsed = this.parseValue(value) + this.value.set(parsed) + + // In case value.set() does not trigger onValueUpdated, we need to reset + // the counter here also. + this._noChangedEvent = false; + } + + override onPropertyChanged(property: string, value: any, prevValue?: any) { + if (this.shownOutputProperties != null) { + const data = this.shownOutputProperties[property] + if (data) + this.setOutputData(data.index, value) + } + } + + /* + * Logic to run if this widget can be treated as output (slider, combo, text) + */ + override onExecute(param: any, options: object) { + if (this.outputSlotName != null) { + const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName) + if (outputIndex !== -1) + this.setOutputData(outputIndex, get(this.value)) + } + for (const propName in this.shownOutputProperties) { + const data = this.shownOutputProperties[propName] + this.setOutputData(data.index, this.properties[propName]) + } + + // Fire a pending change event after one full step of the graph has + // finished processing + if (this._aboutToChange > 0) { + this._aboutToChange -= 1 + if (this._aboutToChange <= 0) { + const value = this._aboutToChangeValue; + this._aboutToChange = 0; + this._aboutToChangeValue = null; + this.triggerChangeEvent(value); + } + } + } + + override onAction(action: any, param: any, options: { action_call?: string }) { + if (action === this.storeActionName) { + let noChangedEvent = false; + let value = param; + if (param != null && typeof param === "object" && "value" in param) { + value = param.value + if ("noChangedEvent" in param) + noChangedEvent = Boolean(param.noChangedEvent) + } + this.setValue(value, noChangedEvent) + } + } + + onConnectOutput( + outputIndex: number, + inputType: INodeInputSlot["type"], + input: INodeInputSlot, + inputNode: LGraphNode, + inputIndex: number + ): boolean { + const anyConnected = range(this.outputs.length).some(i => this.getOutputLinks(i).length > 0); + + if (this.autoConfig && "config" in input && !anyConnected && (input as IComfyInputSlot).widgetNodeType === this.type) { + this.doAutoConfig(input as IComfyInputSlot) + } + + return true; + } + + doAutoConfig(input: IComfyInputSlot, options: AutoConfigOptions = { setDefaultValue: true, setWidgetTitle: true }) { + // Copy properties from default config in input slot + const comfyInput = input as IComfyInputSlot; + for (const key in comfyInput.config) { + if (options.includeProperties == null || options.includeProperties.has(key)) + this.setProperty(key, comfyInput.config[key]) + } + + if (options.setDefaultValue) { + if ("defaultValue" in this.properties) + this.setValue(this.properties.defaultValue) + } + + if (options.setWidgetTitle) { + const widget = layoutState.findLayoutForNode(this.id as ComfyNodeID) + if (widget && input.name !== "") { + widget.attrs.title = input.name; + } + } + + // console.debug("Property copy", input, this.properties) + + this.setValue(get(this.value)) + + this.onAutoConfig(input); + + this.notifyPropsChanged(); + } + + onAutoConfig(input: IComfyInputSlot) { + } + + notifyPropsChanged() { + const layoutEntry = layoutState.findLayoutEntryForNode(this.id as ComfyNodeID) + if (layoutEntry && layoutEntry.parent) { + layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1) + } + // console.debug("propsChanged", this) + this.propsChanged.set(get(this.propsChanged) + 1) + + } + + override onConnectionsChange( + type: LConnectionKind, + slotIndex: number, + isConnected: boolean, + link: LLink, + ioSlot: (INodeOutputSlot | INodeInputSlot) + ): void { + super.onConnectionsChange(type, slotIndex, isConnected, link, ioSlot); + this.clampConfig(); + } + + clampConfig() { + let changed = false; + for (const link of this.getOutputLinks(0)) { + if (link) { // can be undefined if the link is removed + const node = this.graph._nodes_by_id[link.target_id] + if (node) { + const input = node.inputs[link.target_slot] + if (input && "config" in input) { + this.clampOneConfig(input as IComfyInputSlot) + changed = true; + } + } + } + } + + // Force reactivity change so the frontend can be updated with the new props + this.notifyPropsChanged(); + } + + clampOneConfig(input: IComfyInputSlot) { } + + override onSerialize(o: SerializedLGraphNode) { + (o as any).comfyValue = get(this.value); + (o as any).shownOutputProperties = this.shownOutputProperties + super.onSerialize(o); + } + + override onConfigure(o: SerializedLGraphNode) { + const value = (o as any).comfyValue || LiteGraph.cloneObject(this.defaultValue); + this.value.set(value); + this.shownOutputProperties = (o as any).shownOutputProperties; + } + + override stripUserState(o: SerializedLGraphNode) { + super.stripUserState(o); + (o as any).comfyValue = this.defaultValue; + o.properties.defaultValue = null; + } +} diff --git a/src/lib/nodes/widgets/ImageUploadWidget.svelte b/src/lib/nodes/widgets/ImageUploadWidget.svelte new file mode 100644 index 0000000..ef82b4b --- /dev/null +++ b/src/lib/nodes/widgets/ImageUploadWidget.svelte @@ -0,0 +1,344 @@ + + +
+ {#if widget.attrs.variant === "fileUpload" || isMobile} + + {:else} +
+ + +
+
+ +
+
+
+
+ + +
+ + + {#if !$nodeValue || $nodeValue.length === 0} + + + +
+ +
+ {#if uploadError} +
+ Upload error: {uploadError} +
+ {/if} +
+ + + + +
+ {:else} + + +
+ +
+ {#if uploadError} +
+ Upload error: {uploadError} +
+ {/if} +
+ {/if} +
+
+ {/if} +
+ + diff --git a/src/lib/nodes/widgets/index.ts b/src/lib/nodes/widgets/index.ts new file mode 100644 index 0000000..73f3264 --- /dev/null +++ b/src/lib/nodes/widgets/index.ts @@ -0,0 +1,9 @@ +export { default as ComfyWidgetNode } from "./ComfyWidgetNode" +export { default as ComfyButtonNode } from "./ComfyButtonNode" +export { default as ComfyCheckboxNode } from "./ComfyCheckboxNode" +export { default as ComfyComboNode } from "./ComfyComboNode" +export { default as ComfyGalleryNode } from "./ComfyGalleryNode" +export { default as ComfyImageEditorNode } from "./ComfyImageEditorNode" +export { default as ComfyRadioNode } from "./ComfyRadioNode" +export { default as ComfyNumberNode } from "./ComfyNumberNode" +export { default as ComfyTextNode } from "./ComfyTextNode"