import type IComfyInputSlot from "$lib/IComfyInputSlot"; 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 } export type SerializedComfyWidgetNode = { comfyValue?: any shownOutputProperties?: ComfyWidgetNode["shownOutputProperties"] } & SerializedLGraphNode /* * 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 ComfyNumberWidget * - 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 } export type ShownOutputProperty = { type: string, outputName: string } /* * 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; // input slots inputSlotName: string | null = "value"; 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.createDisplayWidget(); this.unsubscribe = this.value.subscribe(this.onValueUpdated.bind(this)) } protected createDisplayWidget(): ITextWidget { const widget = this.addWidget( "text", "Value", "" ) widget.disabled = true; // prevent editing return widget; } addPropertyAsOutput(propertyName: string, type: string) { if (this.shownOutputProperties[propertyName]) return; if (!(propertyName in this.properties)) { throw `No property named ${propertyName} found!` } const outputName = "@" + propertyName; this.shownOutputProperties[propertyName] = { type, outputName } this.addOutput(outputName, 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) { if (this.changedEventName == null) return; // 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) { const index = this.findOutputSlotIndexByName(data.outputName) if (index !== -1) this.setOutputData(index, value) } } } /* * Logic to run if this widget can be treated as output (slider, combo, text) */ override onExecute(param: any, options: object) { if (this.inputSlotName != null) { const inputIndex = this.findInputSlotIndexByName(this.inputSlotName) if (inputIndex !== -1) { const data = this.getInputData(inputIndex) if (data != null) { // TODO can "null" be a legitimate value here? this.setValue(data) } } } 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] const index = this.findOutputSlotIndexByName(data.outputName) if (index !== -1) this.setOutputData(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" && "__widgetValue__" in param) { value = param.__widgetValue__ 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 = this.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() { if (!this.layoutState) return; const layoutEntry = this.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: SerializedComfyWidgetNode) { (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; } }