From 43fc6dc0f5dc96dcc65a104726986f305d49dd71 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 8 Sep 2023 19:00:14 +0100 Subject: [PATCH] add basic widget binding --- web/js/common/binding.js | 244 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 web/js/common/binding.js diff --git a/web/js/common/binding.js b/web/js/common/binding.js new file mode 100644 index 0000000..7533e7e --- /dev/null +++ b/web/js/common/binding.js @@ -0,0 +1,244 @@ +// @ts-check +// @ts-ignore +import { ComfyWidgets } from "../../../../scripts/widgets.js"; +// @ts-ignore +import { api } from "../../../../scripts/api.js"; +// @ts-ignore +import { app } from "../../../../scripts/app.js"; + +const PathHelper = { + get(obj, path) { + if (typeof path !== "string") { + // Hardcoded value + return path; + } + + if (path[0] === '"' && path[path.length - 1] === '"') { + // Hardcoded string + return JSON.parse(path); + } + + // Evaluate the path + path = path.split(".").filter(Boolean); + for (const p of path) { + const k = isNaN(+p) ? p : +p; + obj = obj[k]; + } + + return obj; + }, + set(obj, path, value) { + // https://stackoverflow.com/a/54733755 + if (Object(obj) !== obj) return obj; // When obj is not an object + // If not yet an array, get the keys from the string-path + if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || []; + path.slice(0, -1).reduce( + ( + a, + c, + i // Iterate all of them except the last one + ) => + Object(a[c]) === a[c] // Does the key exist and is its value an object? + ? // Yes: then follow that path + a[c] + : // No: create the key. Is the next key a potential array-index? + (a[c] = + Math.abs(path[i + 1]) >> 0 === +path[i + 1] + ? [] // Yes: assign a new array object + : {}), // No: assign a new plain object + obj + )[path[path.length - 1]] = value; // Finally assign the value to the last key + return obj; // Return the top-level object to allow chaining + }, +}; + +/*** + @typedef { { + left: string; + op: "eq" | "ne", + right: string + } } IfCondition + + @typedef { { + type: "if", + condition: Array, + true?: Array, + false?: Array + } } IfCallback + + @typedef { { + type: "fetch", + url: string, + then: Array + } } FetchCallback + + @typedef { { + type: "set", + target: string, + value: string + } } SetCallback + + @typedef { { + type: "validate-combo", + } } ValidateComboCallback + + @typedef { IfCallback | FetchCallback | SetCallback | ValidateComboCallback } BindingCallback + + @typedef { { + source: string, + callback: Array + } } Binding +***/ + +/** + * @param {IfCondition} condition + */ +function evaluateCondition(condition, state) { + const left = PathHelper.get(state, condition.left); + const right = PathHelper.get(state, condition.right); + + let r; + if (condition.op === "eq") { + r = left === right; + } else { + r = left !== right; + } + + return r; +} + +/** + * @type { Record) => Promise> } + */ +const callbacks = { + /** + * @param {IfCallback} cb + */ + async if(cb, state) { + // For now only support ANDs + let success = true; + for (const condition of cb.condition) { + const r = evaluateCondition(condition, state); + if (!r) { + success = false; + break; + } + } + + for (const m of cb[success + ""] ?? []) { + await invokeCallback(m, state); + } + }, + /** + * @param {FetchCallback} cb + */ + async fetch(cb, state) { + const url = cb.url.replace(/\{([^\}]+)\}/g, (m, v) => { + return PathHelper.get(state, v); + }); + const res = await (await api.fetchApi(url)).json(); + state["$result"] = res; + for (const m of cb.then) { + await invokeCallback(m, state); + } + }, + /** + * @param {SetCallback} cb + */ + async set(cb, state) { + const value = PathHelper.get(state, cb.value); + PathHelper.set(state, cb.target, value); + }, + async "validate-combo"(cb, state) { + const w = state["$this"]; + const valid = w.options.values.includes(w.value); + if (!valid) { + w.value = w.options.values[0]; + } + }, +}; + +async function invokeCallback(callback, state) { + if (callback.type in callbacks) { + // @ts-ignore + await callbacks[callback.type](callback, state); + } else { + console.warn( + "%c[🐍 pysssss]", + "color: limegreen", + `[binding ${state.$node.comfyClass}.${state.$this.name}]`, + "unsupported binding callback type:", + callback.type + ); + } +} + +app.registerExtension({ + name: "pysssss.Binding", + beforeRegisterNodeDef(node, nodeData) { + const hasBinding = (v) => { + if (!v) return false; + return Object.values(v).find((c) => c[1]?.["pysssss.binding"]); + }; + const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional }; + if (hasBinding(inputs)) { + const onAdded = node.prototype.onAdded; + node.prototype.onAdded = function () { + const r = onAdded?.apply(this, arguments); + + for (const widget of this.widgets || []) { + const bindings = inputs[widget.name][1]?.["pysssss.binding"]; + if (!bindings) continue; + + for (const binding of bindings) { + /** + * @type {import("../../../../../web/types/litegraph.d.ts").IWidget} + */ + const source = this.widgets.find((w) => w.name === binding.source); + if (!source) { + console.warn( + "%c[🐍 pysssss]", + "color: limegreen", + `[binding ${node.comfyClass}.${widget.name}]`, + "unable to find source binding widget:", + binding.source, + binding + ); + continue; + } + + let lastValue; + async function valueChanged() { + const state = { + $this: widget, + $source: source, + $node: node, + }; + + for (const callback of binding.callback) { + await invokeCallback(callback, state); + } + + app.graph.setDirtyCanvas(true, false); + } + + const cb = source.callback; + source.callback = function () { + const v = cb?.apply(this, arguments) ?? source.value; + if (v !== lastValue) { + lastValue = v; + valueChanged(); + } + return v; + }; + + lastValue = source.value; + valueChanged(); + } + } + + return r; + }; + } + }, +});